[AGE-4290] fix(sdk): Read the session facts in the agent service, not off the wire - #6667
Conversation
|
@coderabbitai review |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
✅ Action performedReview finished.
|
📘 Docs preview
This comment updates in place on every push. |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 SummarySummary by CodeRabbit
WalkthroughThe agent service now resolves workflow and session facts from the Agenta backend for each turn. The handler ignores client-supplied session metadata, supports injected resolvers, and derives artifact identifiers from request references. Documentation and tests cover timeouts, malformed responses, ambiguity, cancellation, and failed reads. ChangesSession context resolution
Priority: ➖ Normal Estimated code review effort: 4 (Complex) | ~45 minutes Severity of issue fixed: Medium Merge Risk: 🔵 Low · up to The service now derives session facts for playground turns, but the forged-metadata regression test does not distinguish a client-supplied first-turn value from the backend result. This is a bounded test-coverage gap that could allow a future regression in first-turn prompt behavior to go undetected. Sequence Diagram(s)sequenceDiagram
participant Playground
participant AgentHandler
participant SessionResolver
participant AgentaBackend
Playground->>AgentHandler: POST /invoke with session id
AgentHandler->>SessionResolver: Resolve session context
SessionResolver->>AgentaBackend: Read workflow and session facts
AgentaBackend-->>SessionResolver: Return validated backend data
SessionResolver-->>AgentHandler: Return SessionContext or no context
AgentHandler->>AgentHandler: Build SessionConfig
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
… wire The playground posts a turn straight to the agent service, so the API prelude that stamps request.meta.session_context never runs on that path and every playground turn reached the agent with no session facts. The service now reads the three facts itself, with the caller's own credential, and ignores the blob on the wire, which is client input on this path. Fixes #6661
e440927 to
60cdc84
Compare
Railway Preview Environment
Updated at 2026-09-08T19:04:08.042Z |
Codex review on #6667: - Give the optional resolution one TOTAL deadline covering client construction and teardown, not the tool resolver's 30 s per-operation timeout. An expired budget cancels the outstanding reads. An outside cancellation still propagates. - Tell a malformed session body apart from a legitimately unnamed session. A shape this code cannot trust reports UNKNOWN for the whole pair, because reading it as unnamed tells a named session to rename itself. - Decline to resolve an agent name when a request names competing workflow artifact families. Inline-config runs bypass the family validator. Also corrects the API comments that claimed the stamp covers playground turns.
From the Codex review: three requests not two reads, and the stream read touches Redis as well as Postgres. The run context is not universally populated, because inline config skips hydration. A saved artifact with unsaved config is a draft and still has a name. first_turn means no durable turn row at lookup time, so a retry or a cold resume of the first exchange reads False. The session-id shortcut is rare, because the normalizer mints an id before the handler runs. The handler is where turns pass, not every possible SDK entry point. Records why a client is built per call and why the facts must never be cached.
…the unwind Codex round 2 on #6667: - The competing-families check never ran on the service path. The run context is built from the same references and prefers the workflow family, so the early return handed back the very preference the check exists to refuse. The check now runs first. A bare-handler test could not see this, because it has no ambient run context, so the cover is a service-level test that installs one. - One deadline owner, shared by the module and the handler. It runs the work as its own task, so the unwind is bounded by a grace period rather than awaited forever, and a teardown that raises can no longer replace a caller's cancellation with an ordinary exception the boundary would swallow. - Refuse a non-finite timeout override, which would disable the deadline. - Raise the default budget to 2 s. A fresh client per turn pays connection setup, and the stream read does Redis work before Postgres. - Canonicalize reference ids so two families spelling one artifact differently do not read as a disagreement. - Correct the docstring claim that the streams GET route omits null fields.
… route The service-level cover now posts to /invoke rather than calling the handler, so the tracing context that feeds the artifact lookup is built by the route from the request's own references instead of handed in by the test. That is the layer where the competing-families bug was reachable and where a bare handler test could not see it. Reverting the check ordering makes the competing-families cell fail with the wrong agent's name, as Codex reported.
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository YAML (base), Organization UI (inherited)
Review profile: CHILL
Plan: Advanced
Run ID: 78203041-6422-4ddb-9f89-503e18b944e8
📒 Files selected for processing (1)
services/oss/tests/pytest/unit/agent/test_session_context_resolution.py
Included review availability: Your plan provides up to 8 included reviews per hour; 4 remain after this review.
…boundary Codex round 3: - A resolver that raises synchronously before returning its awaitable escaped the boundary, because the call was evaluated before entering it. The typed interface admits such a factory, so run_optional now takes the callable. - The default path nested two deadlines. The outer grace period was watching an inner wrapper unwind rather than the client that holds the connections. The handler now calls the unbounded read, and the bounded entrypoint stays for a caller that reaches past the handler. - A detached cleanup is held in a set that releases on completion. A done callback is not a reference, so the loop could collect one mid-unwind and report 'Task was destroyed but it is pending!'. - Land the docstring correction that reached a test comment last round. - Initialize the SDK singleton in the service tests rather than inheriting it, which made them fail under xdist on a loaded box. - Tighten two loose assertions: all three reads must cancel, and the slow teardown is bounded by budget plus grace rather than a second.
The service tests take the SDK singleton through a fixture that restores what was there, rather than initializing a process global and leaving it installed for every later test in the worker. Adds the guarantee the abandonment path actually makes: cancelling the task raises into the async with, so the client's close is entered every time, and the caller does not wait for it. If that close itself hangs there is nothing further to force, since httpx exposes no way past aclose.
Round 4 nits, both wording and no behaviour change. The one-second threshold also rejected the ten-second teardown, so the tightening is not justified by what the comment said. And host= does not isolate the exporter, because the AGENTA_API_URL the fixture sets wins; the reason its failures are harmless is that the exporter flushes off the request path.
The forged-meta cell sent first_turn false while the backend also answered false, so a route that trusted the forged value passed. The backend turn list is now empty, which makes this the first turn while the forgery denies it, and the rendered marker tells the two answers apart. Injecting trust of only the forged turn position now fails the cell.
…ew (#6687) * test: correct three comments that claimed more than the tests prove Follow-up to #6667, comment and name only, no behaviour change. The exporter comment described the ordering backwards. The fixture sets up before the one that sets AGENTA_API_URL, so the loopback host is used; an ambient value would still win, which is why this is not exporter isolation. The fixture docstring said it owns the SDK singleton. It restores the ag.tracing alias and nothing else, and init also replaces api, async_api and the tracer and installs a provider. The abandonment test was named for a close that always happens. What it can prove is that the close is entered and that the caller does not wait for it. * test(services): make the fixture order real instead of assumed Two function-scoped fixtures with no dependency have no guaranteed order, so a comment describing one was asserting something pytest does not promise. backend_facts now depends on sdk_singleton, which is the fixture whose environment variable the other reads. The comment no longer claims where the exporter points either. init prefers AGENTA_API_INTERNAL_URL and then AGENTA_API_URL over the host passed to it, and either can be ambient, so ordering alone settles nothing. The claim that does hold is that isolation is not needed here.
Context
Fixes #6661. Rename a session, ask the agent what the session is called, and it answers with the old name. It keeps answering the old name on the next turn too, even when you tell it to use only the facts it was given for this turn.
The three per-turn session facts (the agent's display name, the session's name, the first-turn flag) shipped in #6638. The API stamps them on
request.meta.session_contextin its shared invoke prelude, the SDK renders them into oneturnContextstring, and the runner prepends that to the prompt. All three halves work.The playground never reaches the first half. It posts a turn straight to
{origin}/services/agent/v0/invoke, which traefik routes to the agent service, so the API's prelude never runs. Every playground turn reached the agent with no session facts at all. The agent then answered from the only place a name appears, its own earlier reply, which is why the name looked frozen at the value from before the rename.The same request body carries a second problem.
metais client input on that path, so a browser could hand the agent forged facts. The deployed service accepted them. See the live evidence below.Changes
The agent service now resolves the three facts itself, once per turn, in
agenta/sdk/agents/platform/session_context.py. It reads them from the backend with the caller's own credential, the same connection the service already uses for tool and vault resolution:agent_nameGET /api/workflows/{workflow_id}session_nameGET /api/sessions/streams/?session_id=first_turnPOST /api/sessions/turns/query, limit 1The workflow read and the session reads run concurrently. Resolution hangs off
AgentComposition.resolve_session_context, so it is injectable like every other resolver on that seam.The service no longer reads
request.meta.session_contextat all. A service cannot tell the API's stamp apart from a forged copy in its own request body, so it trusts neither. The API keeps stamping the key: that is the wire contract for a workflow service built on an SDK older than this change, and removing it would break those during a rolling deploy.Two details worth a look in review.
The artifact reference has three names. A playground turn labels it
application, a native workflow invocation labels itworkflow, and an evaluator labels itevaluator. All three are workflow-backed and name the same row. The resolver takes the id from the run context, which already normalizes them, and falls back to scanning those three keys when a bare-SDK run has no tracing context. Reading onlyworkflow, the way the API's own_resolve_agent_namedoes, reports no agent name for every playground turn.The session name and the first-turn flag are one fact pair. If either read fails, both report UNKNOWN. An unread name beside
first_turn=Falserenders "This session has no name yet", which tells an already-named session to rename itself. A wrong fact is worse than a missing one, and the renderer already drops any fact it does not have.Every read is best-effort. These facts shape prompt text, so a backend that cannot answer degrades to the pre-#6638 prompt rather than failing the turn.
Why this option
The brief asked for three options in order.
(a) Resolve in the services container. Chosen. Every turn that goes through the SDK's agent handler passes here, whether the API proxied it or a browser posted it directly. Resolving there fixes both paths with one rule, and it is the only place that can honestly refuse to trust the request body. It also means the API's stamp is no longer consumed by the built-in service. That is deliberate, and the reason to keep the stamp is version skew, stated above.
Cost is three concurrent requests per agent turn, so the turn waits for the slowest rather than the sum. I have not measured the added latency and make no claim about it. The backend address comes from SDK configuration, so whether it is an in-cluster hop is a deployment property. The 500 ms budget below is the bound that actually matters: past it the facts are dropped rather than waited on.
(b) Render the turn context in the runner from the heartbeat response. The runner does receive the stream row on every beat, so it could recover
session_name. It cannot reach the other two. The workflow display name is not on that row, and the first-turn flag is not either. It would also put a second renderer next to the SDK's, so the prompt text could drift between the two paths. Rejected.(c) Route the playground through the API invoke path. This is the structurally cleanest answer and it deletes the whole class of bug. It also moves every playground turn onto a different transport, which changes streaming, cancellation, and session admission all at once. That is not a change to make inside a release fix. Worth doing on its own, separately.
Tests
Unit, all green:
sdks/pythonagents unitservicesunitThe pre-existing failure is
test_streaming.py::test_cli_stream_terminal_only_on_empty_request. It fails the same way on the branch point,3e120dc46a.New coverage:
test_session_context_http.py, 13 tests over a mocked backend: the three reads and their exact request shapes, the credential on every read, an unconfigured backend, a run with no session id, a draft run with no artifact, a half-read session reporting neither fact, an unreadable workflow costing only the agent name, blank names reading as unnamed, and unexpected body shapes reading as unknown.test_agent_composition_seam.py: the resolved facts reach the backend as turn text, the resolver receives the session id and the artifact id for all three reference families, the run context wins over the raw reference, a client-suppliedsession_contextis ignored, and a client-supplied one cannot survive a failed resolve.ruff formatandruff checkclean at the CI-pinned 0.15.12. No web changes.Live evidence
Local EE dev stack, harness Pi core, model
openrouter/openai/gpt-4.1-minithrough an OpenRouter connection, sandbox local. Both runs drivePOST /agent/v0/invokewith the exact body the playground sends. Before is the deployed service on the unmodified SDK. After is the same stack, same API, same runner, with the agent service run from this branch.The stored row read
QA-v0.115.3 Vermilion Quayin both runs.assistantQA-v0.115.3 Sapphire LedgerassistantQA-v0.115.3 Vermilion QuayassistantQA-v0.115.3 ctx 9ee0c5ebmeta.session_contextnaming the sessionFORGED Ashen VaultFORGED Ashen VaultQA-v0.115.3 Vermilion QuayThe agent-name row matters on its own. That name never appears anywhere in the conversation, so a correct answer proves the workflow read arrived rather than the model reading its own history.
The forged row is the security half. The deployed service repeats a name the client invented. With this change the service reports the stored name and ignores the forgery.
What to QA
Alt+Rto something that has never appeared in the chat. Ask "What is this session named? Answer with only the name." It answers the new name.Not covered
meta. Unit tests cover the resolution; there is no live run of a trigger fire in this PR.first_turnflag has no live assertion. It is prompt text with no user-visible surface to check, and unit tests pin it.RUN_SESSIONSbut notVIEW_SESSIONSorVIEW_WORKFLOWSnow loses the facts, because the reads carry the caller's own credential rather than server privilege. That degrades to the pre-feat(agents): give every turn current agent and session context #6638 prompt.SessionConfig.session_contextthemselves. This PR does not refresh every possible SDK session.agents/platform/, so changing it belongs to the package rather than to this module alone. Filed as a follow-up, not done here. No fact is cached, and none should be: a rename has to show on the very next turn.invokehelper posts to the same service endpoint, so no gate cell exercised the API stamp, and no cell renames a session between two turns. This PR adds no gate cell. The driver that produced the live table above is written for that shape and is the obvious seed for one.Codex review follow-up (12b6c6b)
Three findings from the xhigh review, all landed with tests.
P1, one total deadline and a complete exception boundary. The reads inherited the tool resolver's 30 s timeout, which is per-operation. A backend that trickles bytes resets it on every chunk while elapsed time grows without bound, and Codex reproduced 0.48 s against a 0.1 s setting. Client construction and teardown also sat outside the catches, so a constructor exception escaped.
The whole optional operation now runs under one budget, 500 ms by default and settable with
AGENTA_AGENT_SESSION_CONTEXT_TIMEOUT. Expiry cancels the outstanding reads. The handler applies the same budget to an injected resolver, which has none of its own. A cancellation from outside propagates rather than being swallowed, because the caller is going away and this optional work should go with it.P2, malformed session data is UNKNOWN, not unnamed.
{"stream": []}beside a valid turns answer used to yield(None, False), which renders "This session has no name yet. Name it withrename_session" at an already-named session. That is the exact bug this PR exists to fix. A numeric name did the same. Both envelopes and both field types are now validated before the pair is asserted, and anything untrusted sends the whole pair to UNKNOWN.The legitimate cases still work. An absent or null
streammeans no session row yet, which the response model produces viaresponse_model_exclude_none, and a null or blanknameis a real unnamed session. Both still get their naming instruction.P2, competing artifact families decline rather than pick. A request carrying
application=Aandworkflow=Bnames two different artifacts. The family validator that would reject it runs only during reference hydration, which an inline-config run bypasses, so the selector silently preferredworkflow. It now reports no agent name when the families disagree, and still resolves when they agree, which is the normal overlapping case.API comments corrected.
_stamp_session_contextclaimed "a UI turn, a HITL resume, and a trigger fire are all covered". A UI turn was never covered. Both that docstring andcore/sessions/context.pynow say what the stamp actually serves and why it stays.New tests covered the total budget, cancellation of the outstanding reads, a constructor exception, the env override, the malformed stream and turns bodies, the legitimately unnamed shapes, and the malformed workflow bodies, plus competing and agreeing artifact families and a slow or raising injected resolver.
Live re-run after these changes, same stack and same five cells, all green. The service logged no resolver warning, so every read landed inside the budget.
Two things the review asked to file rather than fix here, both now open:
Codex round 2 (a5780c8)
The competing-families check never ran on the path that matters. Codex posted an ambiguous inline request through the real service app and got
Your name is "Name of B". The check was there, but the run-context early return sat above it, and the run context is built from those same references and prefers theworkflowfamily. So the early return handed back the exact silent preference the check existed to refuse. The check now runs first.My round-1 test passed because a bare handler has no ambient run context, which is precisely why it could not see this.
The cover is now
services/oss/tests/pytest/unit/agent/test_session_context_resolution.py, which posts to the service's real/invokeroute with the real platform resolver and stubs only the backend HTTP, the harness, and auth. Driving the route matters: it is the route that builds the tracing context from the request's own references, so the run context under test is the real one rather than something the test hands in. Four cells: the facts render end to end, a rename shows on the very next turn, a forgedmeta.session_contextis ignored, and competing families render no agent name while the session half still lands.Reverting the check ordering makes it fail with
assert 'Agent B' not in ..., the same result the review reported through its own probe. The seam test fails alongside it with B's id. Both reproduce the finding rather than restating the fix.One deadline owner, and a bounded unwind.
run_optionalis shared by the module and the handler. It runs the work as its own task, which buys two things a bareasyncio.wait_forcannot. The unwind is bounded by a grace period instead of awaited without limit. And a teardown that raises can no longer replace an in-flightCancelledErrorwith an ordinary exception that the boundary then swallows, which was a real regression the round-1 fix introduced.The default budget is now 2 s, up from 500 ms. Codex is right that a fresh client per turn pays connection and TLS setup, and that the stream read does Redis work before its Postgres lookup. Silent degradation on an ordinary slow day is the worse failure, and 2 s still cannot meaningfully delay runs that take seconds.
Smaller items. A non-finite override such as
infno longer disables the deadline. Reference ids are canonicalized, so two families spelling one artifact differently do not read as a disagreement. The docstring claiming the streams GET route omits null fields was wrong: that route registers no response model at all.Test totals at this head: SDK agents 1359 passed, services unit 167 passed, same single pre-existing failure. Codex root-caused that one for us as a missing
tsx, where pnpm emits the literal stringundefinedand the NDJSON parser rejects it. Live re-run of all five cells green with the 2 s budget and no resolver warning in the service log.Three items from the review I did not take here. Connection pooling, because every adapter in
agents/platform/builds a client per call and the handler has no lifespan hook to own a shared one; that is a package-level change. Tighter callable typing, because the six sibling aliases inhandler.pyuse the same loose form. And metrics, filed as #6671: the review is right that a log warning alone makes this degrade invisibly, but an outcome counter and a duration histogram carry naming and cardinality decisions that do not belong in a release fix.Codex round 3 (047738e)
The artifact-ambiguity blocker is closed, confirmed by the review's own probe through the real service route. Three items from that round are fixed here, two of them defects the previous round's rework introduced.
A resolver that raises synchronously escaped the boundary. The call was evaluated before entering the guard, so a factory that raises before returning its awaitable propagated and broke the turn. The composition field is typed
Callable[..., Awaitable[...]], which admits exactly that.run_optionalnow takes the callable and calls it inside the boundary.Two deadlines nested on the default path. The handler wrapped a resolver that wrapped itself, so when the outer budget expired it cancelled the inner wrapper, and the grace period then watched that wrapper finish rather than the client holding the connections. The mechanism built to bound cleanup was guarding the wrong object. The handler now calls
read_session_context, which carries no bound, andresolve_session_contextstays as the bounded entrypoint for a caller that reaches past the handler. Exactly one deadline owns a turn, and a test asserts that by recording every owner that fires.A detached cleanup could be collected mid-unwind. A done callback is not a reference, so an abandoned task could be garbage-collected and report
Task was destroyed but it is pending!. Detached tasks now live in a set that each entry removes itself from on completion.Both new tests were checked against their own regressions. Re-evaluating the resolver before the guard makes the first fail with the factory's
RuntimeError; restoring the nesting makes the second fail with two owners instead of one.Smaller items. The docstring correction about the streams route landed in the module this time, not only in a test comment. Two assertions were too loose to fail on broken code: the cancellation test now requires all three reads to cancel rather than at least one, and the slow-teardown test is bounded by budget plus grace rather than a full second.
The service tests own the SDK singleton. The route runs the instrumented handler, which reads
ag.tracing, and a worker that has run nothing to initialize it returns 500 onNoneType.get_current_spanin the cell that proves the artifact check works. A fixture now initializes it, asserts it, and restores whatever was there, so a process global does not follow every later test in the worker. This started as a suspected parallel-runner flake; the causal claim was later withdrawn, and the historical failures stay unexplained. The hardening is worth having either way.What the abandonment path does and does not promise. Cancelling the task raises into the
async with, so the client's own close is entered every time, and a test pins that alongside the caller not waiting for it. If that close itself hangs there is nothing further to force: httpx exposes no way pastaclose. The review could not produce a stock-httpx teardown hang either, which is why bounding admission is deferred rather than done.The service tests now take the SDK singleton through a fixture that restores whatever was there. Setting a process global and leaving it installed would follow every later test in the same worker.
Test totals at this head: SDK agents 1365 passed, services unit 167 passed across three consecutive runs, same single pre-existing failure.
The review returned SHIP WITH NITS on this round, with no production blocker. It independently probed each closed item: a synchronous
CancelledErrorstill propagates through the new boundary rather than degrading to an ordinary failure, exactly one deadline owner fires on each path, and a detached task survives a forced garbage collection with no destroyed-task diagnostic. It also measured the deferred admission bound against stock httpx, where five trickling resolutions each finished inside a 0.2 s budget with every connection closed and nothing left detached, which is why that stays hardening rather than a fix. The remaining nits were three inaccurate comments, corrected here.Deferred, with the review's agreement on each. Bounding admission while a cleanup is still outstanding stays open: the leak needed an injected infinite teardown and could not be reproduced against stock httpx. Metrics are #6671. Connection pooling and the callable typing remain package-level concerns. The review also found that a slug-only reference sidesteps an id-to-id ambiguity check, which is pre-existing and now recorded in #6668.