AGENT_ENGINE selects which agent loop runs a turn (default: langgraph) - #197
Merged
Conversation
`AGENT_ENGINE=langgraph|temporal`, defaulting to langgraph — so this commit changes no behaviour, and enabling the engine is always an explicit act. Everything outside the loop is shared either way: the OpenAI facade, /invoke, identity resolution, RBAC, the credential store, the authorization pre-flight, both launchers, session pages. That is the point of doing it this way — the claim ADR 0001 makes is about the loop, and the ~237 commits of churn since its fork point all landed in the layers above it. ## The seam already existed `AgentGraphLike` (invoke + stream) has been the Server's dependency all along, so this is a second implementation of an existing interface rather than a refactor. 581 existing tests pass unchanged. ## HTTP, not an embedded Temporal client The obvious implementation embeds @temporalio/client and does update-with-start from this process. Rejected, in ascending order of importance: 1. A substantial npm dependency for what is one HTTP call. 2. The engine's Go gateway already implements this exact contract (accept an id, poll it), so a TypeScript reimplementation would give one protocol two definitions. 3. It would put Temporal credentials in the pod that already holds the Kubernetes identity. docs/orchestrator.md reasons explicitly about that pod's blast radius; leaving it unchanged is worth an extra hop. ## Two contracts reused rather than bypassed The sender login travels as a SIGNED `x-gateway-user-assertion`, not a body field. It selects the caller's principal and therefore which stored credentials the run receives, so an internal hop is exactly as unsuited to trusting it unsigned as an external one. The Go verifier is byte-compatible with mintSenderAssertion — pinned by generated vectors on the engine side, and round-tripped here. A route target is NAMED, not re-derived: this process owns the IntegrationRoute registry and has already matched it, so `/invoke` gained optional forcedSkillId/forcedAgentId. Re-matching in the engine would put routing policy in two places, which is what ADR 0024 rejected when it declined to let integration-gateway launch AgentRuns directly. The engine still re-resolves whatever is named under the caller's own roles. ## Two real differences, recorded not papered over A streaming caller on this engine gets its answer but no per-node narration: those lines describe LangGraph node transitions, which do not exist there. `persistSession` writes nothing for this engine, because the workflow holds that state itself — which is the entire change. It merges rather than replaces, so an all-undefined outcome is a no-op rather than a clobber. That is also why the flag is process-wide: the two engines keep conversation state in different places, so alternating mid-conversation would lose whichever one it left. A turn that outlives the poll budget is reported as a resumable pause, not a failure. The record IS the workflow rather than this process's memory, so the answer stays collectable — the same shape ADR 0033 settled on, now true by construction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Uj1SJ41DJJ8woZM7fd3DQ
Pins both halves of B2's addition: an explicitly named target wins over re-matching the event descriptor, and its request text is not overwritten by the route's promptTemplate — while a caller that names nothing still gets the event matched, which is what driving the engine directly relies on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Uj1SJ41DJJ8woZM7fd3DQ
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Third PR in the sequence from #194 (ADR 0036), following #196. Adds
AGENT_ENGINE=langgraph|temporal.Defaults to
langgraph, so this changes no behaviour. Enabling the engine is always an explicit act.The claim to check first
AgentGraphLike(invoke+stream) has been the Server's dependency all along, so this is a second implementation of an existing interface, not a refactor.The evidence: 581 existing tests pass unchanged, and the only files touched under
apps/areconfig.ts(+3 values),index.ts(+1 branch picking which engine to construct), and the newengine/directory.graph.tsis untouched.Everything outside the loop is shared either way — the OpenAI facade,
/invoke, identity resolution, RBAC, the credential store, the authorization pre-flight (0030/0031), both launchers, session pages. Only the loop moves. That split is the reviewability argument: the claim is about the loop, and the layers above it are where the last six weeks of work landed.The substantive review is one file
apps/agent-orchestrator/src/engine/temporal-engine.ts. Three decisions in it, each documented at the top of the file:It's an HTTP client, not an embedded Temporal client. The obvious implementation embeds
@temporalio/clientand does update-with-start here. Rejected in ascending order of importance: it's a substantial dependency for one call; the engine's Go gateway already implements this exact accept/poll contract, so a TypeScript reimplementation gives one protocol two definitions; and it would put Temporal credentials in the pod that already holds the Kubernetes identity.docs/orchestrator.mdreasons explicitly about that pod's blast radius — keeping it unchanged is worth an extra in-cluster hop.The sender login travels signed. It selects the caller's principal and therefore which stored credentials a run receives, so an internal hop is exactly as unsuited to trusting it unsigned as an external one. Reuses
x-gateway-user-assertion(0030 §6) — the engine's Go verifier is byte-compatible withmintSenderAssertion, pinned on that side by vectors generated from this TypeScript, and round-tripped in a test here.A route target is named, not re-derived. This process owns the
IntegrationRouteregistry and has already matched it, so the engine's/invokegained optionalforcedSkillId/forcedAgentId. Re-matching there would put routing policy in two places — what ADR 0024 rejected when it declined to let integration-gateway launch AgentRuns directly. The engine still re-resolves whatever is named under the caller's own roles.Two real differences, recorded not papered over
A streaming caller on this engine gets its answer but no per-node narration. Those lines describe LangGraph node transitions, which don't exist there.
persistSessionwrites nothing for this engine, because the workflow holds that state — which is the entire point. It merges rather than replaces, so an all-undefined outcome is a no-op rather than a clobber. That's also why the flag is process-wide rather than per-request: the two engines keep conversation state in different places, so alternating mid-conversation would lose whichever one it left.A turn that outlives the poll budget is reported as a resumable pause, not a failure. The record is the workflow rather than this process's memory, so the answer stays collectable — the shape ADR 0033 settled on, now true by construction.
Tests
9 new tests for the engine client, covering each decision above plus the failure modes: a failed turn reported as an error rather than an empty result, pending caller tool calls surviving the hop (0035's second terminal shape), the assertion refused when unsigned, and an unreachable engine throwing rather than silently degrading.
Locally: typecheck clean, 581 + 9 tests pass, engine module green.
🤖 Generated with Claude Code
https://claude.ai/code/session_018Uj1SJ41DJJ8woZM7fd3DQ