Skip to content

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 Tool definitions;
  • 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.

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 FinalOutputResponse
from 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.

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.

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.

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_id identifies one in-process definition object;
  • run_id identifies one isolated execution;
  • structural_id identifies language-neutral behavior-affecting material.

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.