Skip to content

Import the Temporal agent engine (engines/temporal), no behaviour change - #196

Merged
DavidNic11 merged 30 commits into
mainfrom
feat/temporal-engine-import
Aug 4, 2026
Merged

Import the Temporal agent engine (engines/temporal), no behaviour change#196
DavidNic11 merged 30 commits into
mainfrom
feat/temporal-engine-import

Conversation

@DavidNic11

@DavidNic11 DavidNic11 commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

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:

git diff --stat main...feat/temporal-engine-import -- apps packages   # empty

The AGENT_ENGINE switch is the next PR, and it defaults to langgraph.

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:

  1. Does this belong in the repo at all? That's really ADR 0036: run the agent loop as Temporal workflows, behind AGENT_ENGINE #194's question; this PR is only worth reviewing if the answer there is yes.
  2. Is the history intact? Imported via git subtree, so its seven milestone commits are real history rather than one opaque drop. git log engines/temporal reads as what it is.
  3. Is it wired into CI correctly? See below.
  4. Is the module named to fit? Renamed durable-agentsgithub.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 — and go-version-file picks up that this module needs Go 1.26.

release.yml gains three images (worker, gateway, catalog-sync) sharing one temporal-engine path filter, since they're 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.

The images are still unbuilt — CI here does not prove them

Correcting something I had wrong in the first version of this description: release.yml triggers on push to main, 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 COPY is module-relative) and go build ./... passes, but the first real proof is the release.yml run 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: gofmt clean, 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's ecr-push target. The Makefile now just runs the same four checks CI does.

🤖 Generated with Claude Code

https://claude.ai/code/session_018Uj1SJ41DJJ8woZM7fd3DQ

DavidNic11 and others added 30 commits July 22, 2026 07:08
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
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
…ee8e5a5d'

git-subtree-dir: engines/temporal
git-subtree-mainline: b29e6aa
git-subtree-split: 2fa7a0c
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

@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.

🚢

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