Import the Temporal agent engine (engines/temporal), no behaviour change - #196
Merged
Conversation
Agents as Temporal workflows, successor to agent-controller's pod-based agent loop (see docs/adr/0001). ConversationWorkflow per chat session via update-with-start; each turn is a user-turn Update running one LLM activity; idle-timeout completion and continue-as-new after 40 turns. OpenAI-compatible gateway facade with session headers. Helm chart for gateway + worker assuming an existing Temporal cluster. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMKU1psq2dfXqEnTs994Yi
Move workflows/ and activities/ under internal/temporal/ and extract the client dial + env config shared by gateway and worker into internal/temporal/client.go. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMKU1psq2dfXqEnTs994Yi
Replace the stdlib mux with a gin router (Logger + Recovery middleware, release mode by default, GIN_MODE overridable). Handlers move to gin.Context binding/JSON; SSE streaming uses gin's ResponseWriter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMKU1psq2dfXqEnTs994Yi
- internal/catalog: Tool/Skill/Agent descriptors decoded from agent-controller's v1alpha1 CRs; skill audience derived as the intersection of referenced tools'/agents' allowedRoles (fail closed on dangling refs); indexer with debounced skill re-derivation. - internal/vectorstore: Store port + Qdrant adapter with role visibility enforced at query time (match-any roles OR unrestricted) and re-checked on direct id lookups; integration test against live Qdrant (QDRANT_TEST_ADDR). - internal/llm: OpenAI embeddings client (text-embedding-3-small). - cmd/catalog-sync: dynamic informers -> indexer -> Qdrant. - Worker registers RetrieveSkills/RetrieveAgents/ResolveSkillTools activities when QDRANT_HOST is set; all retrieval fails closed without a resolved subject. - Chart: catalog-sync Deployment + catalog-namespace Role/RoleBinding, qdrant values, worker Qdrant env. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMKU1psq2dfXqEnTs994Yi
- internal/messaging: Go port of the @controller-agent/messaging wire contract — event union with per-type validation, HMAC sha256 sign/verify (vector-tested against openssl). Tool containers are unchanged. - internal/toolrun: Launcher port; K8sLauncher creates ToolRun CRs idempotently and reads mirrored status; FakeLauncher for cluster-less dev (TOOLRUN_MODE=fake). - workflows: runTool helper — SideEffect job id, launch activity, durable await of tool-event signals with seq dedup, timer timeout with ToolRun phase-check backstop. Tool failure is an outcome, not a workflow error. Standalone ToolRunWorkflow with tool-progress query. - gateway: callback bridge on its own listener (CALLBACK_ADDR :8081) — HMAC-verifies the raw body, correlates by URL path (body job_id is tool-authored), signals the workflow; 410 for late events. - chart: worker SA + toolruns Role in the catalog namespace, callback secret env, cluster-internal gateway-callback service. E2E verified against live Temporal: signed progress/succeeded flow, seq dedup, 401 on forged signature, and workflow completion after the terminal event was delivered while the worker was down (the crash case the old pending-promise design loses). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMKU1psq2dfXqEnTs994Yi
Port agent-controller's LangGraph nodes into the durable turn handler: - activities/agentloop: the five LLM decision nodes as structured-output activities — CheckNeedsCapability (ambiguity defaults to the capability path), CheckSkillFit (defaults to re-selection), SelectSkill (validates the returned id against candidates), PlanAction (respond/call_tool/finish with per-turn action history), ComposeResponse (additive prefix/suffix around the verbatim result, ADR 0015). - workflows/agentloop: the turn pipeline — active-skill fit check (ADR 0012 continuity; skill id in durable state, content re-fetched RBAC- checked every turn) → capability gate → RBAC-filtered retrieval → selection → resolve tools → plan⇄runTool loop (max 4 steps, planner tool ids re-validated, repeat-call guard, failures feed back to the planner) → compose. Falls back to a bare answer at every no-match point. - llm.CompleteJSON: response_format json_schema strict mode. - rbac.StaticResolver + gateway bearer→Caller plumbing (fail closed); AGENT_DEFAULT_SUBJECT/_ROLES for tokenless dev. - cmd/dev-seed: sample catalog for cluster-less development. E2E verified: chat turn → gate → Qdrant retrieval → selection → planned tool call → fake launch → HMAC callback → finish → composed reply; second turn rode the active skill (skill_fit only — no gate, no retrieval, no re-selection in the LLM call log). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMKU1psq2dfXqEnTs994Yi
- internal/continuation: port of the <!-- continuation: token --> marker (ADR 0016/0017). Only a LEADING marker is trusted; mid-text markers in tool output are ignored. Round-trip tested. - Agent loop: strips tokens from successful tool results before they reach planner history, compose, or the reply; stores them per-tool in ConversationState.ToolContinuations (durable, survives continue-as-new, never in History); prepends on the same tool's next invocation. - Conversation workflow: per-turn narration buffer behind a turn-progress query — skill selection, tool launches, live tool progress events, and compose steps; the authoritative transcript rides TurnResult.Meta. - Gateway stream:true: update-with-start waits only for Accepted, then polls turn-progress and emits Open WebUI status chunks (+ SSE keep- alives), flushing narration and the reply from the update result on completion. Errors ride the stream as a final message. E2E verified: streamed turn showed live status chunks including a signed mid-tool progress callback; the tool result's continuation marker never appeared in the reply and was prepended server-side to the same tool's next launch (fake-launch log). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMKU1psq2dfXqEnTs994Yi
The AgentRun pod + bidirectional NATS channel becomes a child AgentWorkflow, with the up/down protocol reborn as parent<->child signals: - workflows/agent_workflow: one episode per child, parameterized by the Agent CR fields (agentPrompt + skillRefs merged into role-visible tools, maxIterations). Episode loop: PlanAgentAction -> call_tool (same durable runTool, per-episode continuation tokens) | ask_user (up-signal the question, durably await the prompt signal — no pod idles on a human) | delegate (recursive child, self-delegation filtered, questions bubble up the chain to the human and answers relay back down) | finish. Depth capped at 3 (closes upstream ADR 0001's open question); at the cap children aren't even offered delegation. - activities/delegate: SelectDelegate (skill vs agent vs none, ids validated against candidates, orchestratorPrompt as delegation hint) and PlanAgentAction. - Conversation loop: an active agent episode takes the turn outright (upstream checkActiveAgentRun) — the user message goes down as the HITL answer; delegation starts a child and relays its first non-progress up-signal (question => reply + episode stays active; final => reply + banked agent continuation token). Child progress feeds turn narration. Idle timeout stretches to 24h while an episode waits on the human; ParentClosePolicy reaps abandoned children. Tests: two-turn HITL through a real child in the test env (answer lands in the child planner's history), depth-cap gating, plus the existing suite. E2E: delegation turn returned the child's question with both workflows Running and no pods; the answer turn completed the child and returned the plan. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMKU1psq2dfXqEnTs994Yi
Heavyweight pod agents (opencode-swe-agent) run as checkpoint-resume Jobs
instead of long-lived NATS-connected pods:
- messaging.AgentStepResult: the step envelope ({status: question|final,
message, continuation}) a step Job returns before exiting; plain-string
results degrade to final answers.
- PodAgentWorkflow: each work step is one ToolRun of the agent's step tool
(durable-agents.dev/step-tool annotation on the Agent CR, decoded into
AgentDescriptor.StepToolRef and routed at delegation time). Question
envelopes end the Job; the workflow relays the question up, waits
durably (no pod exists while the human thinks), and launches the next
Job with the answer + the agent's continuation token as a leading
marker. Final envelopes bank the token per-agent for the next episode.
- Identity gate (ADR 0022 mechanics): agents declaring identityProviders
block before the first step until the caller's credential is linked —
the turn's reply is the link instruction; every user reply re-checks
(fail closed). IdentityLinkStore port with a static env-based dev impl.
- docs/pod-agents.md: the full step contract for adapting opencode, plus
the two upstream follow-ups (TS adapter; ToolRunSpec.secretEnv for
per-user token injection).
- Test-env fix: tool events for child-workflow launches must route by the
workflow id captured in the launch input (SignalWorkflowByID), exactly
like the production callback URL; signal helpers now self-reschedule
until the launch exists, removing a virtual-time flake.
E2E: delegation -> step-1 Job exited with a question + token (child
PodAgentWorkflow Running, no pods) -> answer relaunched with the marker
prepended -> final envelope -> reply; child Completed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMKU1psq2dfXqEnTs994Yi
Local no-cluster dev (fake tool mode, hand-signed callbacks, dev-seed) and the full k3s deployment checklist: install order, dual-namespace callback secret, image import, chart install, identity env, smoke checks, and the current pre-milestone-8 limitations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMKU1psq2dfXqEnTs994Yi
The Bitovi platform already runs every dependency (agent-controller wave 40, catalog CRs wave 42, Temporal wave 30, Qdrant in agent-deps wave 41, 1Password operator, Open WebUI) — durable-agents deploys BESIDE the upstream orchestrator in the agent-controller namespace: - qdrant.collectionPrefix (env QDRANT_COLLECTION_PREFIX): namespaces our collections (da-tools/…) — the upstream orchestrator owns tools/skills/ agents in the shared agent-qdrant with a different payload schema, and sharing collections would corrupt retrieval for both sides. - gateway.identity values -> STATIC_IDENTITIES / AGENT_DEFAULT_* env (the knob setup-instructions flagged as missing). - onePasswordItems chart values -> OnePasswordItem CRs (platform 1Password operator syncs the callback HMAC secret; OPENAI_API_KEY reuses the existing agent-orchestrator-secrets). - docs/platform/: stand-up runbook + DRAFT ArgoCD Application and platform values ready to copy into bitovi-platform-services (vendored chart, wave 43, manual ECR push for the first stand-up), including the Open WebUI multi-endpoint config for side-by-side comparison and the gotchas (cluster-internal only, 1d Temporal retention vs 24h HITL window, opencode Agent CR not delegable until the checkpoint adapter). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMKU1psq2dfXqEnTs994Yi
durable-agents is private/local (no CI, unlike agent-controller which the platform pipeline builds from public source), so images are built and pushed from the workstation: login, create-repo-if-missing, linux/amd64 build, push, git-SHA tag matching the platform's version.yaml convention. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMKU1psq2dfXqEnTs994Yi
…atform Platform flow correction: the ECR repos are platform-managed (Crossplane via platform-app-resources in the Argo app, mirroring agent-controller), so stand-up is merge-dormant -> make ecr-push -> tag bump. All three Deployments skip rendering on an empty image.tag (the platform gating convention); local installs keep tag: latest defaults. ecr-push drops create-repository and uses AWS_PROFILE=platform. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMKU1psq2dfXqEnTs994Yi
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMKU1psq2dfXqEnTs994Yi
runAsNonRoot with distroless's string USER nonroot fails kubelet
verification ('non-numeric user'). Set runAsUser/runAsGroup 65532 in the
pod securityContexts (chart-only fix, no rebuild) and numeric USER in the
Dockerfiles for future images.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMKU1psq2dfXqEnTs994Yi
NewClient registers the configured namespace if missing (retention from TEMPORAL_NAMESPACE_RETENTION, default 72h) and waits for the registration to become visible before dialing. Platform values move off the shared default namespace onto agent-controller. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMKU1psq2dfXqEnTs994Yi
durable-agents forked agent-controller at e62b227 (2026-07-21); upstream has since moved 237 commits and added ADRs 0024-0035. This is the first of the catch-up workstreams in docs/upstream-catchup-plan.md — schema only, no behaviour yet reads the new fields. Tool.identityProviders (ADR 0032 §2): a container Tool can now require a linked identity of its own. Previously this only ever arrived via a wrapped Agent CR, so the jobTemplate branch had no identity gate at all. Agent.toolRefs (ADR 0028): which Tools the sub-agent's OWN loop may call, as opposed to skillRefs, which is prompt material. Skill.allowCallerTools (ADR 0035 §4): a *bool, and pinned by a test that unset decodes to nil. nil means ALLOWED — a plain bool's zero value would silently refuse caller tools on every Skill CR predating the feature. IntegrationRoute (ADR 0024): new descriptor, GVR and decoder. Deliberately NOT part of Indexer — routes are matched by exact string equality, never retrieved by similarity, so embedding them would spend a vector round trip to answer a map lookup and would put a routing table into the candidate set skill/tool recall depends on. The decoder re-validates the exactly-one-target rule rather than trusting the cluster's CEL: a route with two targets would otherwise silently pick one. ToolRunSpec.secretEnv (ADR 0032 §1): LaunchSpec.SecretEnv, written onto the created CR. This closes the gap docs/pod-agents.md recorded as blocking per-user credentials on checkpoint-resume step Jobs — upstream added the field after the fork. Note it carries a reference, never a value: anything a workflow puts in its own state is written to Temporal event history durably and in the clear, so the plaintext must stay on the gateway -> launcher -> Secret path. No Qdrant migration needed — descriptors are marshalled wholesale into the point payload, so new fields round-trip on the next resync. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Uj1SJ41DJJ8woZM7fd3DQ
Ports upstream ADR 0024's graph bypass: when an inbound event's intent is
already unambiguous (a specific label applied to a GitHub issue is a discrete
UI action, not a message to interpret), dispatch straight to the named target
instead of asking RAG to infer one.
Engine side only — the /invoke entry point that supplies the event descriptor
lands in A3, which is where the gateway starts RunRouteWatch and reads the
registry. The routing logic itself is complete and tested.
RouteRegistry.Match mirrors upstream's semantics: source/event exact; action
and labelName match exactly when the route names one and wildcard when it
omits one; naming one and having it differ is a MISS, not a fallback, or the
ai-review route would swallow ai-triage. Most specific wins.
Ties break on lexicographic route id rather than insertion order. Upstream
resolves them to whichever route was indexed last, which here would be Go map
iteration order — two processes holding identical routes could dispatch
differently. Two routes tying is an operator mistake either way, but it must
be the same mistake everywhere.
RenderPromptTemplate is a flat {{field}} replace, not a templating engine
(ADR 0024's explicit non-goal). An unmatched placeholder is left verbatim so
an operator's typo is visible in the prompt instead of silently rendering an
instruction with a hole in it. EventFields drops non-scalars and renders whole
floats as integers — an issue number must not reach a prompt as "7.000000".
In the loop, the route check sits AFTER the active-episode check, matching
upstream's edge ordering. Re-applying a trigger label while an agent is still
working the issue feeds the running episode instead of starting a second one;
on a real coding agent the alternative is a second branch and a second PR.
The named target is re-resolved under the caller's CURRENT roles via a new
ResolveAgent activity (agents) or the existing ResolveSkillTools (skills). A
route is operator config, not an authorization decision: config saying
"dispatch to this agent" must not become a way around the roles that gate
reaching it normally. A miss falls through to ordinary retrieval, never errors.
Also fixes a wart the route watch test exposed in both watchers: cache
sync polls on a 100ms period and reports false the moment its stop channel
closes, so a process told to shut down during startup logged a cache failure
it never had. Now distinguished from a genuine sync failure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Uj1SJ41DJJ8woZM7fd3DQ
The gateway wiring for IntegrationRoute (starting the watch, reading the registry) belongs with the /invoke handler that supplies the event descriptor, so it ships in A3 rather than A2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Uj1SJ41DJJ8woZM7fd3DQ
Mechanical, no behaviour change. All six files were already unformatted before the catch-up work started; Phase B puts gofmt in CI and several of these files are edited again in later workstreams, so clearing it now keeps it out of a feature diff. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Uj1SJ41DJJ8woZM7fd3DQ
Wires up the entry point A2's routing engine was built for, and replaces upstream's in-memory invocation record with the workflow itself. ## The invocation record is the workflow POST /invoke does update-with-start and waits only for Accepted, returning "<workflowId>.<updateId>". GET /invoke/:id reconstructs the update handle from Temporal. Upstream keeps this record in an in-process Map. ADR 0006 documents the resulting restart/scale-out loss, and ADR 0033 closes by saying the interrupted turn itself is still lost, because fixing it "means durable invocation records, which this does not attempt". There is no longer a record to lose: any gateway replica can answer a poll, and a gateway that dies mid-turn costs the caller nothing. This is the clearest single piece of evidence for the upstream PR. Bound worth knowing, documented at the call site: an update result is readable while its workflow is retained, so a conversation that idles out after 30 minutes takes its updates with it. The SDK's update handle has no peek, so a poll is a Get under a 2s deadline. Our own deadline means "still running"; a NotFound means the id names nothing; anything else is a turn that ran and failed, reported as 200 status:failed so an adapter can tell "it went wrong" from "ask me later". ## Sender assertion, byte-compatible Go port of mintSenderAssertion/verifySenderAssertion. The test vectors were GENERATED by running upstream's TypeScript, not derived by reading it — an assertion minted by either implementation must verify with the other, and the field order in the payload struct is load-bearing because the signature covers the encoded JSON. If that ever breaks, these tests fail rather than integration-gateway's turns silently losing their sender identity and degrading to the shared service subject. Stricter than upstream in one place: a three-part JWT-shaped string is rejected outright, where upstream's split-and-destructure would read the first two parts. Neither implementation ever mints one. The trust rule is the security core of ADR 0030 §6 and has its own tests: with a secret configured the login comes ONLY from a verified assertion and the body field is ignored entirely, because anything holding this endpoint's token could otherwise name an arbitrary login and be handed that person's credentials. Unset is still supported so upgrading a deployment does not silently break it, and both ends warn at startup. The login is read outside the route match on purpose: the principal must resolve for every event-driven turn, or cross-entry-point credential sharing would quietly depend on routing config. ## Also - Gateway starts RunRouteWatch and holds the registry, making A2 reachable. Both the route table and the shared secret are optional; without cluster access every turn just uses retrieval, as before the feature existed. - TurnInput.SenderLogin is carried through the loop unread until A4 consumes it in the authorization pre-flight. - Request shaping is extracted from the handler so the trust rules are testable without standing up Temporal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Uj1SJ41DJJ8woZM7fd3DQ
Closes two gaps that predate the fork and one upstream fix from after it. The container-tool identity gate moves to A4, where the credential plumbing it needs actually gets built — see the plan doc. ## The fallback cascade (pre-existing gap) A turn matching no skill and no agent went straight to a bare LLM answer; upstream has tried a single direct tool call first since before the fork, and durable-agents never had it. Now: sweep the whole role-visible catalog, gate each candidate with CheckToolFit, hand only survivors to the planner against a synthetic skill whose markdown tells it to decline rather than force a guess, and re-validate the chosen id — same discipline as the skill loop. CheckToolFit is the part that makes this safe. Similarity search matches on word overlap, so "create a recipe" surfaces a tool described as "create or clone a repository". It is a second, narrower judgment that defaults to false on error, on an unparseable response, and on doubt: the one failure direction that matters is a parse failure greenlighting an ad-hoc tool call. Fit checks run concurrently as started futures collected in order — independent judgments, deterministic result, and a turn that already missed the catalog should not pay for them serially. Either branch carries the self-improvement footer, and the footer is stripped before the reply is folded into durable history. Upstream strips it on the way back in (buildAgentRequest); here the transcript IS workflow state, so it is stripped on the way in. Left there, its "no existing skill or agent matched" wording re-enters every later prompt and biases selection toward repeating "no match" for requests that plainly fit a real skill. Pinned by a test that reads the actual prompts of a second turn. The capability gate's bare answer is deliberately a different thing: a greeting was never a catalog miss, so it gets no footer and no sweep. ## The out-of-scope guard (upstream 8e05c6b) Active-skill continuity asks "is this still the same task?", which cannot see that the turn names a capability the skill's own tools could never satisfy. "Use your kubectl access to debug this" mid-web-search passes the fit check, gets absorbed, and the user gets a flat "I can't do that" from a system that in fact can. Divergence worth flagging: scope is the union of what the skill DECLARES and what actually RESOLVED for this caller, where upstream compares against the declared refs alone. Neither an RBAC-hidden ref nor an unpopulated descriptor can then make one of the skill's own tools look foreign and send an ordinary continuing turn back through full retrieval. Found because the test fixture had Tools without ToolIDs — impossible in production, but it showed the comparison had a single point of failure. Fixture fixed too. ## Also runToolWithContinuation extracts ADR 0017's token handling from the skill loop so both paths share it. A tool's resume contract does not change based on how the tool was selected, and the stripping-before-history rule is not one to re-implement per call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Uj1SJ41DJJ8woZM7fd3DQ
…history Ports upstream ADR 0029/0030/0031/0034's authorization work: one owner, plain control flow, batch pre-flight, principals, lazy adoption — plus the container-tool identity gate moved here from A7, since it needs this plumbing. Replaces the dev-only IDENTITY_LINKS store, which is deleted. ## The Temporal-specific hazard, and the shape it forces Upstream keeps a resolved credential in a node-local variable so it never reaches graph state. Doing only the equivalent here would be strictly WORSE than upstream, not equal: an activity result that lands in workflow state is written to Temporal event history, durably and in the clear, for the whole retention period. So authz.Authorize resolves credentials, writes them straight into a Kubernetes Secret, and returns only that Secret's NAME plus the env var names it carries. internal/authz/secrets.go is the only code in the system that handles a credential value. The pre-flight resolves, it writes, the launcher references, the kubelet reads — and a test marshals a Verdict exactly as Temporal would and asserts no token appears in it. A Secret write failure is an ERROR, not a verdict: a launch must never proceed believing it holds credentials it does not. ## Faithful ports, with tests naming the production incident each prevents - Batch pre-flight: nothing short-circuits, so CRD provider order stops being load-bearing. A failed start is reported ALONGSIDE the others. - One start retry, no backoff growth. A silent start failure turns "authorize once" into two rounds for near-identically-labelled credentials, which reads to a user as an auth loop. - Principal step runs FIRST and is link-only: it contributes a mapping and no credential, which is the conflation behind upstream's production 401. - A pending PRINCIPAL stops the turn before other providers, or their flows would file credentials under a subject the caller is one link away from abandoning. - PerUser gates both establishing a principal and adopting a credential. A shared webhook subject does neither — filing a login there would hand one person's credentials to every later senderLogin-less turn. - A lookup that ERRORS is not an answer of "no link": it degrades to the raw subject WITHOUT offering a link, so a gateway blip costs sharing rather than putting a one-time-setup prompt in front of someone who linked months ago. - Logins are case-normalized; Imaustink and imaustink are one record. ## Divergences worth review Waiting: upstream holds one multi-minute fetch per link flow and documents it as fragile — a rollout, an idle intermediary or undici's headers timeout all surface as "fetch failed" mid-wait, and ADR 0033 is entirely about what happens when the process holding a wait disappears. Here the gateway's wait is called with a SHORT timeout and the workflow's durable state spans the human's attention. Both mechanisms, not one as the other's fallback — ADR 0034's own watch-plus-poll reasoning. Whether to wait at all is per-request (Request.WaitForLink), set from whether the caller is streaming. A fire-and-forget caller must not wait: the link reaches them only in the turn's result, so waiting would hide the prompt for the whole window and could only ever time out. The gate runs in the PARENT conversation, not the child agent workflow: the verdict decides whether to start a child at all, the link prompt becomes the turn's reply directly, and the pending anchor belongs to the conversation. The child carries only a reference and never re-decides authorization. ## Also - resumePendingLink ports checkPendingIdentityLink. The anchor captures the ORIGINAL request, so a resume re-delegates the goal rather than "ok, linked it" — with a test for exactly that, and another asserting the user's word is not evidence a link completed. - Container Tools finally have an identity gate (ADR 0032 §5); before this only an agent-backed Tool had one, so a Tool meant to act as the calling human could only ever run with a shared static token. It fails closed, and it is applied on the ad-hoc fallback path too — a Tool reached without a skill must not skip a check a Tool reached through one has to pass. - docs/pod-agents.md's remaining upstream gap is now closed end to end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Uj1SJ41DJJ8woZM7fd3DQ
Ports upstream ADR 0035 — the third level of tool calling, alongside the orchestrator's own Skill-scoped loop and a sub-agent's internal loop. Unlike both, these come from the request body and are executed by the CALLER's client; this system only decides one fits, hands back tool_calls, and resumes when the client resends. Previously the tools field was silently ignored: a client that offered tools got prose back with no way to tell whether the agent declined to call them or never saw them. That is the defect the ADR exists to fix, which is why malformed input is REJECTED with an OpenAI-shaped 400 rather than dropped. ## Why it costs nothing when unused Definitions are keyed by sha256 of the normalized definition, so the collection IS the embedding cache: identical tools embed once ever, across all callers and turns. Since a client sends a near-identical array every turn, the steady-state cost is zero. Schemas are canonicalized (recursively key-sorted) first — without that, a client serializing non-deterministically would miss the cache on every single turn, which is the entire cost the key exists to avoid. The hash covers description and schema too: an edited tool that keeps its name is a different definition and must not resolve to the old one's embedding. Below top-K (default 5) the store is not consulted at all — no embedding, no vector round trip. All JIT vectorization is confined to the case that motivates it: a caller with a large array that would otherwise drown a skill's own 1-5 declared tools in the planner prompt. ## Isolation and trust Its own `caller_tools` collection, never the catalog's, so a caller's ephemeral definitions cannot enter another caller's candidate set, the no-match fallback's catalog sweep, or a sub-agent's toolRefs resolution. Search is filtered to hashes taken from the request being served, which makes cross-caller leakage structurally impossible — and is why this one store needs no RBAC filter: authorization is vacuous for a function the caller both supplied and will run themselves. Search also resolves back to the request's own descriptors rather than trusting stored payloads. Ids are namespaced `caller:<name>`, so a caller name can never collide with or shadow a Tool CR — and the planner's re-validation cannot be tricked into resolving one to the other. Definitions render in a distinctly-labelled untrusted block, one level below a Tool CR description and two below skill markdown, with an explicit note that a caller tool takes a JSON object where catalog tools take a sentence. ## Ordering that turned out to be load-bearing The housekeeping short-circuit runs BEFORE any workflow is started or touched. A chat UI's title-generation request arrives at the same endpoint and can carry the client's tool array; without this it could emit a tool call the client then executes as a side effect of rendering a chat title. Tested with a nil Temporal client, so reaching update-with-start would panic — passing proves nothing touched a workflow. The duplicate-call guard moved BEFORE both dispatch branches. A test caught this: with the guard after the caller branch, a resumed turn whose planner re-issues the byte-identical call (which tool_choice "required", re-applied on the resend, pushes it to do) re-offered the same call to the client forever. The guard is about the planner repeating itself, which is independent of whose tool it chose. It also carries the seeded result through, or the facade renders an empty answer for a turn whose result exists only in seeded history. splitMessages now scans BACKWARD for the user turn: a resuming client sends user -> assistant(tool_calls) -> tool, so taking the last element would read a tool result as the request. ## Also - Second non-error terminal shape on TurnResult, rendered in the blocking facade, the streaming facade, and /invoke's polled record. /invoke can offer caller tools but cannot complete the round trip — it takes a request string, not a message array, so a caller has nowhere to put the result; it is reported rather than surfacing as an empty success. - Seeded history bounds a resumed loop for free: the step cap counts history length, so a client cannot drive an unbounded planner loop by resending. - Skill.allowCallerTools honoured; nil means allowed. - Prune counts before deleting, because Delete reports an operation status and a sweep that reclaimed nothing looks identical to one that reclaimed everything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Uj1SJ41DJJ8woZM7fd3DQ
Ports upstream ADR 0028's Agent.spec.toolRefs — the tools a sub-agent's OWN loop may call, as distinct from skillRefs, which is prompt material. ## Much cheaper here, and worth saying so in the PR Upstream needs a tool_call/tool_result NATS message pair, a callId-keyed pending map (not the single-slot ask() shape, since an agent may have several calls outstanding), an AgentSession.callTool SDK method, an onToolCall callback threaded through awaitReply at each of its call sites, and a dispatchResolvedTool path duplicated from the parent's runTool — all because its sub-agent is a separate process reachable only over a socket. A child workflow just calls runTool. "Let an agent call a tool" becomes a lookup plus a merge. Upstream's own scope cuts follow from that same difference: its onToolCall is wired at two of three awaitReply sites because the third lacks the wrapped Agent's toolRefs, and its container-Job branch cannot forward progress because callTool() resolves to a single value with no channel back. Neither constraint exists here. ## The non-RBAC lookup, and why it is not a hole Resolution is by id with NO role filter, via a new Store.GetByIDsUnfiltered — the one deliberate exception to this package's RBAC discipline, and it answers a different question. Every other read asks "which records may this CALLER reach?", because the system is deciding on that caller's behalf. toolRefs asks "which tools did the OPERATOR declare this agent may call?" — deployed configuration, independent of whoever's turn launched the agent, and the same question upstream's own reconciler validation asks. A test pins it: a caller with no roles at all sees nothing through role-filtered retrieval and still gets the declared set. Routing it through the filtered read would need a synthetic caller-roles filter that either coincidentally works or requires threading the launching caller's roles across the whole life of a long-running agent, for no benefit. ## Divergence: the identity gate applies here too A declared container Tool with identityProviders passes the same ADR 0032 §5 gate the parent's loop applies, and fails closed. Upstream's sub-agent dispatch path skips it, so a Tool meant to act as a specific human would run with whatever static token its template carries — the gap ADR 0032 closed on the parent's path. The refusal enters the agent's planner history as a failed step, so the agent can react rather than silently retrying. Same v1 scope cut as upstream: an agent-backed Tool named in toolRefs is dropped rather than recursively launching another agent. A ref naming nothing is simply absent, and a failed catalog read logs and continues — an agent keeps working with a narrower toolset rather than failing to start over a stale ref. ## Test-harness fix signalToolSuccess now routes by the workflow id carried in the launch input, mirroring how the gateway's callback bridge routes by the id in the callback URL. It previously signalled the root workflow unconditionally, which worked only because every tool call happened to belong to the conversation; a child-agent tool call is delivered nowhere under the old shape, which is exactly what the first run of these tests showed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Uj1SJ41DJJ8woZM7fd3DQ
ADR 0001 §6 dropped NATS. That reasoning holds for agents we write and not for the ones already running: since the fork upstream built the live opencode tunnel (0026), sub-agent tool calls (0028) and the reply-ack hold (0033) on that channel, and claude-code-swe-agent — which speaks it — became the production triage agent. Requiring it to be rewritten as a precondition for merging would trade the maintainer's working system for our preference. So a third execution style: BridgedAgentWorkflow drives an UNMODIFIED AgentRun over the existing protocol. Image, protocol and CR unchanged; the only thing that changes is which side of the conversation is durable. All three styles speak the same parent-facing signal protocol, so a conversation cannot tell them apart. ## Where the thesis gets its sharpest demonstration ADR 0033 exists because an agent turn's work lives in a Job pod while the turn's WAIT lives in an orchestrator pod, and the second lifetime is far shorter — eleven rollouts in fourteen hours, in the incident that prompted it. Core NATS discards a reply nobody is subscribed to, so the fix was to make the agent hold its concluding message, re-offering every 10s until acked. A workflow does not get rolled away, so the bridge acks on receipt — which ADR 0033 names as its own exit condition. But the hold is not deleted, and the ack ORDERING is why: the bridge process is not the workflow. The ack is what tells the agent it may stop holding, so it is sent only AFTER Temporal accepts the signal. A crash in between leaves the agent still holding, which is the recoverable state. There is a test for exactly this — signal fails, no ack is sent, the agent re-offers, and the re-offer is what finally lands. Re-offers reuse their original seq precisely so a consumer can tell a re-offer from a second reply, so a duplicate is re-ACKED (the agent is still holding because an earlier ack did not arrive) but never re-signalled. A test asserts three deliveries of one reply produce one workflow signal and three acks. ## The rest of the protocol - Subjects mirror upstream's agentSubjects exactly; diverging would make an unmodified agent unreachable, which is the whole point. Pinned by a test. - A question is a non-final reply and the answer is the next prompt — no dedicated message pair, because a human may answer across chat turns and no reply timeout can apply. One AgentRun spans the whole episode. - tool_call/tool_result (ADR 0028) dispatches through the ordinary runTool, re-validated against the operator's declared toolRefs at call time and gated on identity. An undeclared tool gets a clean refusal on the wire, not silence. - Ready is bounded separately (5m) from post-ready silence (30m): a pod that never becomes ready is an image pull failure or a crash loop, not a slow agent, and should surface in minutes. - Live-tunnel traffic (opencode_event, session_idle, opencode_response) is ignored rather than treated as an error — an agent using the tunnel still emits an ordinary final reply, which is the contract this needs. - Attach happens before the CR is created, because core NATS has no replay: a `ready` published before the subscription exists is gone, and the workflow would wait forever for something already said. - Re-attach is idempotent and derives subjects from the run id, so a worker that restarted mid-episode can still reach a running agent. That is what makes the bridge itself disposable. ## A8: docs ADR 0002 records D1-D4 with the reasoning, including the two that are really security decisions: authorization stays in TypeScript upstream (two owners of credential keying is ADR 0030 §1's bug), and a credential must never enter workflow state because event history is durable plaintext — a STRONGER property than upstream's, not an equal one. ADR 0001 §6 is amended in place rather than left to read as still-current. pod-agents.md now documents both styles and its last upstream gap is closed. ## Test-harness fix The two planner fakes indexed [-1] on an empty list, surfacing as an opaque activity panic three retries deep and then a misleading "planner failed" warning. They now say what is actually missing from the test setup. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Uj1SJ41DJJ8woZM7fd3DQ
Answers the open question that was the strongest argument against the whole change. The subchart takes an address rather than bundling a server, so no new stateful component is introduced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Uj1SJ41DJJ8woZM7fd3DQ
Brings docs/adr/0001's Temporal agent engine into this repo via git subtree, so its seven milestone commits and both ADRs survive as the design record rather than arriving as one opaque drop. `git log engines/temporal` reads as the history it is. No behaviour change: nothing references the engine yet. The AGENT_ENGINE switch is the next commit, and it defaults to langgraph. ## Module renamed to match this repo's convention `durable-agents` -> `github.com/controller-agent/temporal-engine`, matching core-controller, localtool-executor and http-get-go. A module named after the fork it came from would read as unassimilated. ## CI Its own job rather than a row in the existing `go` matrix: that matrix is explicitly for modules with no external dependencies and disables setup-go's cache for exactly that reason, while this module has a go.sum worth caching. Same four checks as the others (gofmt, build, vet, test) plus the cache, and `go-version-file` picks up that this module needs Go 1.26. ## release.yml Three images (worker, gateway, catalog-sync) share one `temporal-engine` path filter, since they are one module and change together. Built from `engines/temporal` as their context, not the repo root. Which is also why `engines` joins .dockerignore: every Node image builds from the root context, and this module is nothing but dead weight in it. ## Not verified here The image builds themselves — no Docker daemon available in this environment. The contexts are self-contained (every COPY is module-relative) and `go build ./...` passes natively, but the first CI run is what proves it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Uj1SJ41DJJ8woZM7fd3DQ
Resolves one conflict in release.yml's image matrix: the ssh tool landed on main while this branch added the three engine images, and both edit the same jq object. Keeps both keys — dropping either would silently stop rebuilding that image on change, which is the failure mode nobody notices until a stale image is deployed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Uj1SJ41DJJ8woZM7fd3DQ
This was referenced Aug 4, 2026
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.
Second PR in the sequence from #194 (ADR 0036). Imports the Temporal agent engine into
engines/temporal/and wires it into CI and the release matrix.No behaviour change. This PR touches zero TypeScript — nothing references the engine yet, so the LangGraph path cannot be affected. That's the cheapest thing to verify here, and it's worth verifying first because it makes everything below low-stakes:
The
AGENT_ENGINEswitch is the next PR, and it defaults tolanggraph.What the review is actually asking
Not "read 17,000 lines of Go". The engine was reviewed as it was written — it carries its own two ADRs under
engines/temporal/docs/adr/— and from this PR forward its tests run in CI on every change.The ask is four things:
git subtree, so its seven milestone commits are real history rather than one opaque drop.git log engines/temporalreads as what it is.durable-agents→github.com/controller-agent/temporal-engine, matchingcore-controller,localtool-executorandhttp-get-go. A module named after the fork it came from would read as unassimilated.CI
Its own job rather than a row in the existing
gomatrix: that matrix is explicitly for modules with no external dependencies and disablessetup-go's cache for exactly that reason, while this module has ago.sumworth caching. Same four checks as the others — gofmt, build, vet, test — andgo-version-filepicks up that this module needs Go 1.26.release.ymlgains three images (worker, gateway, catalog-sync) sharing onetemporal-enginepath filter, since they're one module and change together. Built fromengines/temporalas their context, not the repo root — which is also whyenginesjoins.dockerignore: every Node image builds from the root context, and this module is nothing but dead weight in it.The images are still unbuilt — CI here does not prove them
Correcting something I had wrong in the first version of this description:
release.ymltriggers on push tomain, not on pull requests, so this PR's CI does not build the three images. It runs gofmt / build / vet / test for the Go module, and that's it.No Docker daemon was available where this was prepared either, so the images have not been built anywhere yet. The build contexts are self-contained (every
COPYis module-relative) andgo build ./...passes, but the first real proof is therelease.ymlrun after merge. If a Dockerfile is wrong, that's where it surfaces — worth knowing before merging rather than after.Locally and in this PR's CI:
gofmtclean,go build ./...,go vet ./..., all 12 packages' tests pass under the new module path.Also dropped
The fork's Bitovi-specific deployment kit —
docs/platform/(ArgoCD app + values) and the Makefile'secr-pushtarget. The Makefile now just runs the same four checks CI does.🤖 Generated with Claude Code
https://claude.ai/code/session_018Uj1SJ41DJJ8woZM7fd3DQ