Repository navigation
Conversation
…cal contract The engine now judges every inbound wire message by the canonical contract (framework/protocol) instead of its own hand-written rules, which had drifted from the TypeScript side: tool callbacks (ParseEvent) and agent up-messages must pass messaging.CheckContract before they are decoded, and down-messages are built as protocolv1.AgentDownMessage and encoded through it. The engine's protocol structs stay. They are Temporal signal payloads, activity results and workflow state, recorded in history, so swapping them for generated types would change how in-flight workflows decode on replay; and passing a tool result through google.protobuf.Value would reorder its keys and round large integers before the model saw them. The contract decides validity; the existing structs carry what passed. Fixes found on the way: - an agent's fractional `pct` made its up-message undecodable (*int); it is now a float and is signalled - an empty prompt was published without `message` (omitempty), which the agent rejects; prompts now always carry it - UpMessage encoded a non-final reply without `final` The contract module is consumed by version from GitHub, as a third party would, so image builds whose context is engines/temporal are unchanged (verified by building a copy of the directory in isolation). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012JeZxPBqi4AMN2YXFrQcYz
|
To continue, please link your Claude Remote Control account. This is a one-time step -- send any message once you're done. |
|
🤖 Starting work on this now. |
|
🤖 Watch live or take over the session here: https://claude.ai/code/session_01RiD6p8jBWwFWZ1edGpqKNs |
| // The wire contract decides validity (see messaging.CheckContract); UpMessage | ||
| // is only how a valid message is carried into the workflow. Dropping an | ||
| // invalid message is the same outcome an undecodable one has always had. | ||
| if err := messaging.CheckContract(data, &protocolv1.AgentUpMessage{}); err != nil { |
There was a problem hiding this comment.
The comment's claim — "the same outcome an undecodable one has always had" — holds only for reply/failed. For every other up-type it's a new, silent drop of messages that used to flow: before this change handleUp did json.Unmarshal only, which never failed on missing optional fields, so the message was forwarded. Running the contract (protocolv1.AgentUpMessage + protovalidate) now rejects, and drops:
progress/warning→ requiremessagesession_idle→ requiresliveUntilsession_ended→ requiresreasontool_call→ requirescallId,tool,inputfailed→ requirescodeandmessage
(verified by running protovalidate against each type.) The "behavior tightening" section of the PR only lists the event/callback side (url on accepted, stage on progress) — it doesn't mention these up-message requirements, and they aren't covered by a positive test (the gate tests only exercise reply and the invalid cases).
Two concrete failure modes if a real SDK message ever omits one of these:
- Non-concluding types (
progress,warning,session_idle,session_ended,tool_call) are dropped permanently — onlyreply/failedare held-and-reacked, so there's no re-offer; the narration/lifecycle event is simply lost. - A
failedmissingcode/messageis dropped and, being concluding, never acked, so the agent re-offers it forever and the turn never concludes — strictly worse than the pre-change "forward it" behavior.
Please confirm the agent SDK always populates all of these for every up-type, and add positive gate tests for the non-reply types (tool_call, session_idle, session_ended, ready, opencode_event). If SDK compliance can't be guaranteed, consider logging-but-forwarding (or at least not silently dropping) non-concluding up-messages.
🤖 Prompt to fix this with an AI agent
In imaustink/agent-controller on PR #296, the up-message contract gate in
engines/temporal/internal/agentrun/bridge.go (handleUp, around line 124,
messaging.CheckContract against protocolv1.AgentUpMessage) now silently drops
up-messages that the pre-change code forwarded. protovalidate requires:
message on progress/warning, liveUntil on session_idle, reason on
session_ended, callId/tool/input on tool_call, code+message on failed.
Non-concluding messages that fail are lost with no re-offer; a failed missing
code/message is dropped yet never acked, so the agent re-offers forever and the
turn never concludes.
1) Verify (against the agent SDK in the framework/ TS sources) that every
up-message type always carries the fields the contract requires. If any type
can legitimately omit a field, change handleUp so non-concluding up-messages
that fail the contract are logged and still forwarded (or otherwise not
silently dropped), while keeping the gate for concluding reply/failed.
2) Add positive bridge tests that a valid ready, tool_call, session_idle,
session_ended and opencode_event up-message passes the gate and is signalled
to the workflow. Do not change unrelated behavior.
There was a problem hiding this comment.
Thanks — the comment was wrong and the description incomplete; both fixed in 8bbeba2. I kept the gate rather than log-and-forward, for three reasons:
- It's the LangGraph engine's existing behavior.
agent-orchestrator/src/agents/nats-agent-channel.ts(decode) drops any up-message failingAgentUpMessageSchema, and feat(protocol): canonical .proto wire contract + TS/Go codegen + conformance suite #295's conformance fixtures hold that zod schema and the proto contract identical (no divergences inagent_up.json). Forwarding would make the two engines disagree again. - No real producer can send an invalid one. The only producers — the agent SDK (
runtime.ts) and opencode-swe-agent — build every up-message through the typedAgentUpMessage, so a missingmessage/liveUntil/reason/callId… is a compile error. - The failure mode is bounded: a dropped
failedisn't re-offered forever — the agent gives up at its reply-ack timeout, as it would if the bridge were down.
Added TestEveryValidUpMessageTypePassesTheGate: all 12 shapes those producers emit (ready, progress ×2, warning, reply ×2, failed, tool_call, opencode_event, opencode_response, session_idle, session_ended) reach the workflow; concluding ones are acked, narration isn't. Mutation-tested (gating against the wrong message type fails all 12). The PR description's behavior-tightening section now lists every up-message requirement.
There was a problem hiding this comment.
Reviewed the edge-adoption approach — it's the right call, and the three bug fixes (Pct → *float64, final/message no longer omitempty) are each well-targeted and test-pinned. I verified locally: go test ./internal/{agentrun,messaging,gateway}/... passes, and the result-bytes-pass-through test confirms the up-path preserves large integers / key order.
One substantive concern, left inline: the new handleUp contract gate silently drops up-messages that the pre-change code forwarded, for more types than the "behavior tightening" section lists (progress/warning need message, session_idle needs liveUntil, session_ended needs reason, tool_call needs callId/tool/input). For non-concluding types this is unrecoverable (no re-offer), and a failed missing code/message would be dropped-but-never-acked, stalling the turn. Worth confirming SDK compliance across all up-types and adding positive gate tests.
I also checked the down tool_result path (result routed through structpb.Value): it's fine in practice because ToolResult always json.Marshals the result as a string, so the Value only ever holds a StringValue — no integer rounding or key reordering. No change needed there.
Not approving/blocking — just flagging the one item to sanity-check.
|
Review posted to PR #296. SummaryI checked out Verdict: The edge-adoption design is sound, and the three bug fixes ( One substantive issue, posted inline at One thing I explicitly cleared (noted in the summary, no change needed): the down ✅ Opened pull request: imaustink/agent-controller#296 |
Review feedback: the bridge's up-message gate rejects more than the PR described (message on progress/warning, liveUntil on session_idle, reason on session_ended, callId/tool/input on tool_call, code+message on failed), and only reply and invalid cases were tested. Keeps the gate, because it is what the LangGraph engine already does: its nats-agent-channel drops any up-message failing AgentUpMessageSchema, and the conformance fixtures hold that schema and the proto contract identical. Every real producer (the agent SDK and opencode-swe-agent) builds up-messages through the typed AgentUpMessage, so the required fields are compile-time guaranteed. Adds a positive test that all 12 up-message shapes those producers emit reach the workflow (concluding ones acked, narration not), and corrects the handleUp comment, which wrongly called the drop pre-existing behavior and implied an unacked message is re-offered forever (the agent gives up at its reply-ack timeout). Mutation-tested: gating against the wrong message type fails all 12 cases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012JeZxPBqi4AMN2YXFrQcYz
Stack 4/6 — based on #295 (proto contract) ← #294 ← #293.
The plan said "swap the engine's hand-written mirrors for the generated Go types." Doing that wholesale would have been unsafe.
messaging.Event,agentrun.UpMessageandTurnEventaren't just parsing types: they are Temporal signal payloads, activity results and workflow state, so their JSON is recorded in workflow history. Replacing them with generated structs would:resultthroughgoogle.protobuf.Value, which reorders keys, reformats numbers and rounds integers past 2^53 before the model sees themSo the engine adopts the contract at its wire edges:
gateway/callback.go→ParseEvent)Event.Validate()(drifted)messaging.CheckContract(generatedprotocolv1.Event+ protovalidate), then the sameencoding/jsondecode as beforeagentrunbridge)json.Unmarshalonlypublish)json.Marshal(DownMessage)protocolv1.AgentDownMessage, validated,protojson-encodedThe contract decides validity; the existing structs carry what passed. Raw result bytes pass through untouched (pinned by a test).
Bugs this fixed (each pinned by a test that fails on the old code)
pctdropped the whole message.UpMessage.Pctwas*int, so an agent reporting42.5was "undecodable". It's now*float64, which still decodes old history ints.message.omitemptydropped it, and the agent's schema requires it.UpMessageencoded a non-final reply withoutfinal.omitemptyagain.Behavior tightening (please sanity-check)
Both edges now enforce the full contract, exactly as the LangGraph engine already does. Its callback receiver and
nats-agent-channel.tsdrop anything failing the zod schemas, and the #295 conformance fixtures hold zod and the proto contract identical.Tool events (callbacks):
urlonacceptedandstageonprogress.JobEmitter.accepted(url)/progress(stage)take these as required arguments.Agent up-messages (NATS bridge). Before, the Go bridge forwarded these with missing fields zero-valued; now it drops them. The required fields per type:
progress/warningmessagereplymessage,finalfailedcode,messagetool_callcallId,tool,inputopencode_responserequestId,statussession_idleliveUntilsession_endedreasonNo real producer can omit these. The agent SDK and opencode-swe-agent (the only direct producers) build every up-message through the typed
AgentUpMessage, so a missing field is a compile error.TestEveryValidUpMessageTypePassesTheGatepins all 12 shapes they emit: each reaches the workflow, concluding ones are acked, narration isn't. A dropped concluding message is not acked, so the agent re-offers it until its reply-ack timeout and then gives up, the same as if the bridge were unreachable.Only test fixtures were non-conformant, and they're updated.
How the engine gets the contract
It requires
github.com/imaustink/agent-controller/framework/protocol/goby version from GitHub (noreplace), the same way a third-party orchestrator would. Image builds with contextengines/temporal(skaffold, and your external pipeline) are unchanged. Verified by buildingcmd/worker+cmd/gatewayfrom a copy of the directory outside the repo.Dependency bumps from MVS after adding protovalidate-go 1.4: testify 1.11→1.12, x/text, x/sync, yaml, genproto (all patch/minor).
Not changed
TurnEventhas no external wire edge in this engine yet: it lives in workflow state and query results. Its internalskill_idtags stay, because changing them would affect continue-as-new payloads. When ADR 0004 B2 exposes events to clients, encode throughprotocolv1.TurnEventthere. The generated decoder already accepts the old snake_case spelling (a #295 fixture covers it).Verification
gofmtclean ·go vet ./...clean ·go test ./...: all 16 packages pass · the new tests fail against the pre-change files (checked by swapping them back in).🤖 Generated with Claude Code
https://claude.ai/code/session_012JeZxPBqi4AMN2YXFrQcYz