Specialist agents in Python
Use a Junjo Agent for a focused application capability such as product
support, exchange handling, or order status. Give each specialist its own
instructions, tools, and evaluation cases so your coding agent can investigate
and improve that domain without changing every part of the application.
The coding agent edits and evaluates your software. The application Agent described here handles runtime requests inside that software.
Agent is a reusable, typed Junjo executable for the case where a model
chooses the next capability at runtime. It is a sibling of Workflow. An
Agent has no fabricated Graph, and a Tool is an operation rather than a Node.
The definition declares all behavior-affecting structure up front:
- a stable application-owned key and display name;
- exact instructions and typed input/output boundaries;
- one provider-neutral
ModelDriverBinding; - an ordered collection of explicitly typed
Tooldefinitions; - positive model-request and Tool-call limits.
Every execute call creates a fresh run identity, private runtime Store, transcript,
counters, usage aggregate, lifecycle snapshot, and per-run collaborator cache.
The caller owns request dependencies, history selection, persistence, provider
credentials, transactions, and product transport.
An optional store_factory creates typed application state for each execution.
Alternatively, pass store=app_store to borrow a live Store also used by other
Agents or Workflows. Tools receive it through context.store, and successful
results include detached application_state separately from the final output.
Read Store composition for both options and
runnable library integrations for a complete setup.
Minimal definition
Section titled “Minimal definition”The core package deliberately owns no provider SDK. A ModelDriver translates
one immutable normalized ModelRequest into either FinalOutputResponse
or ToolCallsResponse.
from pydantic import BaseModel
from junjo import ( Agent, AgentLimits, ModelDriverBinding, ModelDriverDescriptor,)from junjo.agent import FinalOutputResponsefrom junjo.agent.testing import ScriptedModelDriver
class Question(BaseModel): text: str
class Answer(BaseModel): text: str
agent = Agent( key="answer_question", name="Answer Question", instructions="Answer using available evidence.", input_type=Question, model=ModelDriverBinding.per_run( descriptor=ModelDriverDescriptor( driver_key="scripted", provider="junjo", model="scripted-v1", fixture=True, ), factory=lambda: ScriptedModelDriver([ FinalOutputResponse(output={"text": "deterministic answer"}) ]), ), tools=[], output_type=Answer, limits=AgentLimits(model_requests=4, tool_calls=8),)
result = await agent.execute( Question(text="What happened?"), dependencies=None,)The successful result always contains validated output, a detached normalized
transcript, usage evidence, identities, counters, and terminal reason. Failure
raises a typed AgentError; there is no output-less success result.
This example uses a deterministic test driver, not a live model. For a live
Agent, implement ModelDriver.request() around your chosen provider client or
library and normalize its response into Junjo’s typed contract. The
model-driver guide explains that boundary.
The
AI Chat provider adapters
show application-owned Gemini and Grok driver wiring; these are example code,
not built-in provider clients in the Junjo package.
Improve a specialist independently
Section titled “Improve a specialist independently”Create datasets around a specific capability, then verify the complete user journey after each change:
| Specialist | Focused scenarios | Evidence to inspect |
|---|---|---|
| Product support | Ambiguous product names, conflicting specifications | Retrieval results and facts used in the response |
| Exchange requests | Damaged items, expired windows, missing receipts | Policy lookup, eligibility decision, and tool arguments |
| Order status | Partial shipments, stale tracking, missing orders | Lookup results and whether the answer preserves uncertainty |
An AgentTarget makes a native Agent available to the evaluation harness. This
example wraps the definition above and projects its answer for an evaluator:
from junjo.evaluation import AgentInvocation, AgentTarget
answer_target = AgentTarget( key="answer_question", name="Answer Question", input_version=1, input_type=Question, factory=lambda input_value, context, resources: AgentInvocation( agent=agent, input=input_value, dependencies=None, ), projector=lambda result, input_value, context, resources: result.output.text,)Register the target and the chosen evaluator in your application’s
EvaluationHarness. The
per-run scripted driver above gives each test fresh fixture state. Real targets
should construct the actual specialist and pass its real dependencies through
the same boundary.
Use the dataset and run lifecycle to compare prompt, tool, or model changes. A specialist’s better pass rate is evidence for those cases; rerun the intake/routing and end-to-end targets to check the application still selects and combines specialists correctly.
Tools and dependencies
Section titled “Tools and dependencies”A Tool declares an object-root input schema, an output schema, and exactly one
shared service or synchronous per-run service factory. The asynchronous service
receives validated input plus a read-only AgentRunContext. Dependencies are
opaque application services and never enter the ModelRequest automatically.
Before invoking any Tool factory or service in a returned batch, Junjo checks whole-batch Tool-call admission and validates every call’s declared arguments in model order. Calls then run sequentially and each validated result is committed before the next call begins.
Tool-call limits count Tool invocations; application operations performed inside a Tool retain their own quotas and admission policies. Earlier external side effects are not rolled back when a later call fails.
Limits and errors
Section titled “Limits and errors”Model-request and Tool-call limits are always positive. Budget admission occurs
before work starts, and an over-budget Tool batch is rejected as a whole.
Provider errors, malformed responses, unknown Tools, invalid arguments, service
errors, invalid Tool outputs, and invalid final output have distinct typed
errors in junjo.agent. asyncio.CancelledError propagates unchanged.
An unexpected failure in Junjo-owned admitted machinery is surfaced explicitly
as AgentInternalError with the original exception preserved as its cause;
it is never mislabeled as a provider or Tool failure.
History is optional, provider-neutral, immutable, and must contain complete prior exchanges. Junjo does not create persistent conversation memory.
A Junjo execution trace and state snapshot describe an execution; they are not durable checkpoints for restarting application resources. Persisted job state, remote acknowledgments, replay decisions, compensation, and resource cleanup belong to the application and its chosen job system. Cancellation propagation does not prove that an external operation was undone.
Portable boundaries and deterministic identity
Section titled “Portable boundaries and deterministic identity”Every value that can enter state, telemetry, a ModelRequest, Tool arguments,
Tool results, usage, settings, or a structural fingerprint must be portable
I-JSON. Junjo rejects non-finite floats, integers outside the interoperable
[-9007199254740991, 9007199254740991] range, non-string object keys, and
Unicode lone surrogates. Rejection occurs at the owning boundary before the
value can mutate live state or produce an unencodable OTLP payload.
Decoded JSON has a maximum nesting depth of 128: the root is depth 0, and
object names, object values, and array elements are children. A deeper value
fails at its owning boundary.
JsonValue and FrozenJsonValue in junjo.agent describe the public
portable shapes. Frozen values are detached recursively; callers cannot mutate
a request, result transcript, diagnostic snapshot, or stored evidence through
a retained input reference.
Concrete JSON values take a strict JSON path and must preserve every supplied
value; declared defaults may add missing members. Non-JSON typed values take a
strict Python path and must retain their exact type and equality after declared
serialization and strict JSON detachment. Structured objects reject undeclared
members, and only declared external aliases are accepted. A lossy typed value
is not reported as a full diagnostic candidate before this proof succeeds.
Use dict[str, T] for intentionally open data; set-like types, one-shot
iterables, and non-string mapping keys are not portable Agent boundaries.
Boundary declarations support Pydantic models, dataclasses, typed dictionaries,
and discriminated unions, including unions nested in lists. An object-only
union can also be a Tool’s root input type. Application field names and
discriminator values such as type have no reserved meaning to Junjo; defaults
and examples remain application data. Invalid declarations raise
AgentConfigurationError or ToolConfigurationError at construction, naming
the failing input_type or output_type and explaining the underlying reason.
Agent and Tool structural identities use RFC 8785 canonical JSON and the
versioned Junjo schema-normalization profile. The profile removes generated
annotation titles, canonicalizes reachable local definitions and set-valued
schema keywords, preserves application properties named title and ordered
applicators, and rejects duplicate set members. Therefore class renames and
object insertion order do not change semantic identity, while instructions,
schemas, model settings, Tool order, or limits do.
Three identities remain deliberately separate:
definition_ididentifies one in-process definition object;run_ididentifies one isolated execution;structural_ididentifies language-neutral behavior-affecting material.
Typed results and diagnostic state
Section titled “Typed results and diagnostic state”Agent[InputT, OutputT, DependenciesT] preserves all three public generic
boundaries. Static checking proves that execute(InputT, dependencies=DependenciesT) returns AgentExecutionResult[OutputT] and
rejects crossed input, dependency, or Tool-service output types.
After admission, typed failures expose an immutable AgentStateSnapshot in
error.state (also available as error.evidence). The snapshot is a
detached diagnostic projection, not the private live Store state. It contains
portable input/history/transcript evidence, usage, coherent counters, Tool-call
partitions, final-output availability, and terminal reason. Admission failures
have no Store snapshot because no run state was published.
Successful AgentExecutionResult construction enforces the same public
identity, transcript, usage, and counter invariants. Its terminal reason is
always final_output; Junjo never returns a partially successful result.