Skip to content

AGENT_ENGINE selects which agent loop runs a turn (default: langgraph) - #197

Merged
DavidNic11 merged 3 commits into
mainfrom
feat/temporal-engine-switch
Aug 6, 2026
Merged

AGENT_ENGINE selects which agent loop runs a turn (default: langgraph)#197
DavidNic11 merged 3 commits into
mainfrom
feat/temporal-engine-switch

Conversation

@DavidNic11

Copy link
Copy Markdown
Collaborator

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/ are config.ts (+3 values), index.ts (+1 branch picking which engine to construct), and the new engine/ directory. graph.ts is 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/client and 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.md reasons 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 with mintSenderAssertion, 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 IntegrationRoute registry and has already matched it, so the engine's /invoke gained optional forcedSkillId/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.

persistSession writes 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

DavidNic11 and others added 3 commits August 2, 2026 21:23
`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

@imaustink imaustink left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚢

@DavidNic11
DavidNic11 merged commit 8c2308e into main Aug 6, 2026
5 checks passed
@DavidNic11
DavidNic11 deleted the feat/temporal-engine-switch branch August 6, 2026 18:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants