You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
OpenAI-compat: AtomicAgent Mode.JSON on factory and AgentConfig, not default tools mode #282
Working config (from_openai + Mode.JSON on factory and AgentConfig)
AtomicAgent takes an Instructor-wrapped client. Default AgentConfig.mode is tools mode and the default model is gpt-5-mini. An OpenAI-compatible Chat Completions host that does not speak tools needs Mode.JSON on bothinstructor.from_openai(...) and AgentConfig(...). The modes must match. from_provider("openai/...") is the first-party host.
A Chat Completions host whose public catalog is GET https://api.pzero.studio/v1/models (no key) fits if base_url is https://api.pzero.studio/v1.
importinstructorfromopenaiimportOpenAIfromatomic_agentsimportAtomicAgent, AgentConfig, BasicChatInputSchema, BasicChatOutputSchemaclient=instructor.from_openai(
OpenAI(api_key="<Bearer key for that host>", base_url="https://api.pzero.studio/v1"),
mode=instructor.Mode.JSON,
)
agent=AtomicAgent[BasicChatInputSchema, BasicChatOutputSchema](
config=AgentConfig(
client=client,
model="deepseek-v4-flash",
mode=instructor.Mode.JSON,
),
)
Hi, I'd like to take this issue. I've read the relevant code on main and reproduced the problem; here's my analysis and proposed scope, and I'd appreciate your take on two decisions before/while I implement.
Root cause (verified against atomic_agents/agents/atomic_agent.py)
There are two independent Mode knobs, and they serve different purposes:
The mode on the Instructor client (set at instructor.from_openai(..., mode=Mode.JSON)) determines the actual API call format. AtomicAgent.run() never passes a mode to Instructor, so this is decided entirely at factory time.
AgentConfig.mode (atomic_agent.py:94, default Mode.TOOLS, introduced with the accurate token counting work in a978204) only drives the token accounting: _build_tools_definition() (atomic_agent.py:395) uses it to decide whether to count the schema as a tools definition or append it to the system message. That path only has an observable effect when max_context_tokens is set.
Consequences, worst first:
Factory not set to Mode.JSON (default TOOLS): requests still go out with tools and the host rejects them. This is the failure mode reported here.
Modes disagree (e.g. client JSON, AgentConfig.mode left at default TOOLS): silent wrong token accounting. My repro shows the mismatch undercounting by 83 tokens for the same context (115 vs 198), so max_context_tokens users think they are within budget when the real request is larger, and trimming never triggers. No warning is raised.
Related latent inconsistency in the same area: _serialize_history_for_token_count() hardcodes item.to_openai(Mode.JSON) (atomic_agent.py:456) regardless of self.mode.
Proposed scope
L1 — Derive the default. Make AgentConfig.modeOptional[Mode] = None; when None, AtomicAgent.__init__ derives it from the client (getattr(client, "mode", Mode.TOOLS)), so a single instructor.from_openai(..., mode=Mode.JSON) is enough for the common path. Backward compatible: explicit values keep their current meaning; making the field optional only widens it.
L2 — Warn on mismatch. When an explicitly passed mode disagrees with the client's mode, log an actionable warning. I'd compare by semantic family (tools-family vs JSON-family) so Mode.GENAI_TOOLS on the client vs Mode.TOOLS on the config is not flagged, and skip clients whose mode attribute is missing or not a Mode instance (test mocks use Mock(spec=...), and mode is an instance attribute, so it is not exposed on the spec'd mock).
L3 — Fix token accounting. Use the agent's effective mode instead of the hardcoded Mode.JSON in _serialize_history_for_token_count(), after verifying to_openai() behaves sanely across modes.
L4 — Docs. Make the "two knobs, must match" rule explicit in claude-plugin/atomic-agents/skills/framework/references/providers.md (the current note is one sentence under the matrix), docs/api/agents.md (AgentConfig.mode is not in the field list), docs/guides/quickstart.md, and the AgentConfig.mode field description itself.
Out of scope: the Instructor call path itself, other providers' behavior.
Two decisions for you
L1 acceptable? Deriving the default from the client removes the "two places, must match" trap for new users; the alternative is to leave the default as TOOLS and only do L2–L4.
Warn vs raise on mismatch (L2)? I lean warning: a raise risks breaking valid same-family setups, and the reported pain is silence, not absence of an exception. Happy to escalate to ValueError if you prefer.
I'm starting the implementation now and will open the PR with the repro, the analysis above, and tests covering mode derivation, the family-aware warning, and mock-client safety. The PR is a request for review, so if you'd rather I adjust the direction (especially L1), just say so and I'll rework it.
I'm developing this with an AI coding agent assisting, which I'll state in the PR description as well.
It implements all four items from the proposed scope:
L1 — AgentConfig.mode is now Optional[Mode] = None and follows the client's mode when omitted, so a single instructor.from_openai(..., mode=Mode.JSON) is enough. Explicit values keep their current meaning (the change only widens the field).
L2 — an explicitly configured mode whose family disagrees with the client's logs an actionable warning. Family comparison avoids false positives for equivalent provider-specific modes (e.g. GENAI_TOOLS on the client with TOOLS on the config); clients that expose no Mode attribute fall back to TOOLS as before.
L3 — _serialize_history_for_token_count() now uses the agent's effective mode instead of a hardcoded Mode.JSON.
L4 — the two knobs and their relationship are documented in the providers reference, the API reference (mode was missing from the AgentConfig field list), and the quickstart guide.
The PR includes the repro from my analysis and 10 new tests; the full core suite passes (365 passed, 3 skipped — same 3 pre-existing live-integration skips as before, 355 was the count on a clean main).
The PR is a request for review rather than a merge request, so the two questions from my first comment still stand:
Is the L1 default-derivation acceptable, or would you rather keep the TOOLS default and only land L2–L4?
Warning vs ValueError for the mismatch case — I chose warning to avoid false positives across provider-specific modes and to stay compatible with existing configurations.
Happy to trim the PR to just L2–L4, or to escalate the warning to a raise, whichever you prefer.
Confirmed with a mocked request: the Instructor factory sets the wire format, while AgentConfig.mode affects accounting and can silently disagree. For now, set Mode.JSON on both when using JSON mode. Keeping this open while the mode-consistency fix is reviewed.
Activity
Andiii208 commentedon Oct 3, 2026
Hi, I'd like to take this issue. I've read the relevant code on
mainand reproduced the problem; here's my analysis and proposed scope, and I'd appreciate your take on two decisions before/while I implement.Root cause (verified against
atomic_agents/agents/atomic_agent.py)There are two independent
Modeknobs, and they serve different purposes:instructor.from_openai(..., mode=Mode.JSON)) determines the actual API call format.AtomicAgent.run()never passes a mode to Instructor, so this is decided entirely at factory time.AgentConfig.mode(atomic_agent.py:94, defaultMode.TOOLS, introduced with the accurate token counting work in a978204) only drives the token accounting:_build_tools_definition()(atomic_agent.py:395) uses it to decide whether to count the schema as a tools definition or append it to the system message. That path only has an observable effect whenmax_context_tokensis set.Consequences, worst first:
Mode.JSON(default TOOLS): requests still go out with tools and the host rejects them. This is the failure mode reported here.JSON,AgentConfig.modeleft at defaultTOOLS): silent wrong token accounting. My repro shows the mismatch undercounting by 83 tokens for the same context (115 vs 198), somax_context_tokensusers think they are within budget when the real request is larger, and trimming never triggers. No warning is raised._serialize_history_for_token_count()hardcodesitem.to_openai(Mode.JSON)(atomic_agent.py:456) regardless ofself.mode.Proposed scope
AgentConfig.modeOptional[Mode] = None; whenNone,AtomicAgent.__init__derives it from the client (getattr(client, "mode", Mode.TOOLS)), so a singleinstructor.from_openai(..., mode=Mode.JSON)is enough for the common path. Backward compatible: explicit values keep their current meaning; making the field optional only widens it.Mode.GENAI_TOOLSon the client vsMode.TOOLSon the config is not flagged, and skip clients whosemodeattribute is missing or not aModeinstance (test mocks useMock(spec=...), andmodeis an instance attribute, so it is not exposed on the spec'd mock).Mode.JSONin_serialize_history_for_token_count(), after verifyingto_openai()behaves sanely across modes.claude-plugin/atomic-agents/skills/framework/references/providers.md(the current note is one sentence under the matrix),docs/api/agents.md(AgentConfig.modeis not in the field list),docs/guides/quickstart.md, and theAgentConfig.modefield description itself.Out of scope: the Instructor call path itself, other providers' behavior.
Two decisions for you
TOOLSand only do L2–L4.ValueErrorif you prefer.I'm starting the implementation now and will open the PR with the repro, the analysis above, and tests covering mode derivation, the family-aware warning, and mock-client safety. The PR is a request for review, so if you'd rather I adjust the direction (especially L1), just say so and I'll rework it.
I'm developing this with an AI coding agent assisting, which I'll state in the PR description as well.
Andiii208 commentedon Oct 3, 2026
Update: the PR is open — #293
It implements all four items from the proposed scope:
AgentConfig.modeis nowOptional[Mode] = Noneand follows the client's mode when omitted, so a singleinstructor.from_openai(..., mode=Mode.JSON)is enough. Explicit values keep their current meaning (the change only widens the field).GENAI_TOOLSon the client withTOOLSon the config); clients that expose noModeattribute fall back toTOOLSas before._serialize_history_for_token_count()now uses the agent's effective mode instead of a hardcodedMode.JSON.modewas missing from theAgentConfigfield list), and the quickstart guide.The PR includes the repro from my analysis and 10 new tests; the full core suite passes (365 passed, 3 skipped — same 3 pre-existing live-integration skips as before, 355 was the count on a clean
main).The PR is a request for review rather than a merge request, so the two questions from my first comment still stand:
TOOLSdefault and only land L2–L4?ValueErrorfor the mismatch case — I chose warning to avoid false positives across provider-specific modes and to stay compatible with existing configurations.Happy to trim the PR to just L2–L4, or to escalate the warning to a raise, whichever you prefer.
Eigenwise commentedon Oct 4, 2026
Confirmed with a mocked request: the Instructor factory sets the wire format, while AgentConfig.mode affects accounting and can silently disagree. For now, set Mode.JSON on both when using JSON mode. Keeping this open while the mode-consistency fix is reviewed.
fix(agents): align mode-driven token accounting with prepared requests