feat(compass-agent): agent comms tools — post and list over the Runner socket - #15
Conversation
This stack of pull requests is managed by Graphite. Learn more about stacking. |
| * non-test caller. The registration leg is tracked separately — until it lands, | ||
| * the end-to-end contract is exercised only by this package's tests. | ||
| */ | ||
| export function createCommsTools(broker: CommsBroker): AgentTool[] { |
There was a problem hiding this comment.
There was a problem hiding this comment.
Correct, and confirmed at source — this is real, not a false positive. It is tracked in this PR's Open Questions as #2, but your framing is sharper than mine and I am adopting it: I wrote it as "no non-test caller," which reads like a coverage gap. Yours states the consequence — the tools are absent from the running agent's tool list. Same fact; only one of them tells a reader the feature does not work.
Verified rather than agreed-with:
grep -rn createCommsTools --include=*.ts packages/ | grep -v '\.test\.ts'
index.ts:12 export { ... createCommsTools } from "./comms"; <- re-export only
comms.ts:255 export function createCommsTools(...) <- the definition
agent.ts, the construction path:
147 if (control.tools !== undefined)
148 this.#session.agent.setTools(control.tools);
So the only setTools in the repo is driven by an inbound control message, never by the natives. Nothing installs them.
Why it ships this way rather than being wired here: agent.ts:44-49 names the seam explicitly — the session is "constructed by the caller (container entrypoint) via createAgentSession with its model/tools/system-prompt." That entrypoint does not exist in this repository yet. There is no place to register the tools that would not amount to inventing the boot path inside a PR whose subject is the tool set. comms.ts:236-238 carries the same statement as a header warning, so the file does not claim to be wired.
The adjacent hazard is worth stating alongside it, because it is the part that bites after the entrypoint lands: setTools assigns rather than merges. A config control carrying tools would silently revoke the comms surface. Whether the natives must survive a config.tools control is a design call — parked for Matt in the PR body, not resolved unilaterally overnight.
Leaving this open rather than resolving it: the gap is real until the entrypoint exists, and a P1 marked resolved on a feature that is not reachable would be exactly the kind of true-at-one-scope claim this PR's review rounds have spent their time removing.
There was a problem hiding this comment.
Confirmed, and now with a line number on the branch that fixes it — compass-agent-1359-agent-base-image adds the first real agent boot in this repo, and it does not pass tools:
cli.ts:201 await new CompassAgent({ session, sink, control }).run();
^ session, sink, control — no tools
On main, git grep 'new CompassAgent(' matches agent.test.ts only. So the accurate statement is narrower than "the tools are unregistered": nothing boots a Compass agent in this repo at all today, and the branch that changes that leaves the tool argument off. This finding is correct and sharper than the way I had it in my own open questions, where I'd framed it as createCommsTools having no non-test caller — a property of the source. Stating it as the outcome (the feature is unreachable) is the better framing.
Deliberately not resolving this thread, and not fixing it here. packages/compass-agent/src/cli.ts is another owner's file on another owner's branch, and this PR's diff does not include it (git diff --name-only origin/main...HEAD | grep -c cli.ts → 0). Reaching across to wire a call site in a branch I don't own would create the cross-branch prose rot this repo has already been bitten by tonight.
What exists instead of a fix here:
- The registration requirement is recorded on SEA-1359, the issue whose stated subject is that entrypoint, with the merged-tree evidence:
git merge-treeof the two branches exits 0, and mergedsrc/index.ts:13carriesexport { CommsBroker, type CommsTransport, createCommsTools }. There is no conflict to resolve, only one call site to add —createCommsTools(broker)returnsAgentTool[]. - SEA-1532 (High) captures the hazard that constrains that wiring:
agent.ts:147-148isif (control.tools !== undefined) setTools(control.tools), andsetToolsassigns rather than merges, so registering the natives at construction lets any later inboundconfig.toolscontrol silently revoke the comms surface mid-session — no error, no log, and the agent cannot report that it has lost the ability to report. Matt ruled that fork: natives survive; restriction belongs at the server as D9 visibility enforced in SQL, not in which tools the agent can call.
So this PR ships the broker, the two tool implementations, the schemas, and the barrel export — with createCommsTools exercised by this package's own tests — and the feature becomes reachable when SEA-1359's entrypoint registers it. Recorded as a dependency rather than closed as done: this PR merging does not make the tools callable, and the issue stays open until they are.
There was a problem hiding this comment.
Re-verified on the live artifact rather than carried forward. My earlier note cited the sealed-side branch name; the gap is unchanged at compass#7's current head 15231d9:
cli.ts:201 await new CompassAgent({ session, sink, control }).run();
^ no tools argument
git grep createCommsTools|CommsBroker in cli.ts -> 0
canary: createAgentSession in the same file -> 7
(so the zero is a real absence, not a blind grep)
Also still absent on main: no cli.ts at all, and new CompassAgent( matches only agent.test.ts. So the P1's premise holds exactly as stated — nothing boots a Compass agent in this repo yet, and the branch that will leaves the tool argument off.
Staying open, and staying unfixed here. packages/compass-agent/src/cli.ts is another owner's file on another owner's open PR; reaching across to wire a call site in a branch I do not own is the cross-branch rot this repo has already been bitten by. The requirement is recorded on SEA-1359 with the merge evidence (git merge-tree exits 0; merged src/index.ts:13 exports createCommsTools), so it is one call site to add, not a conflict to resolve.
One thing that has changed since I last wrote here: compass#21 is no longer stacked on this PR. It was retargeted onto main (disjoint file lists, merge-tree clean against a calibrated instrument), because it fixes one of the two reds blocking compass#19's pipeline and gating a CI fix behind this open finding bought nothing. So this thread no longer holds up anything but its own PR.
There was a problem hiding this comment.
Re-verification noted — the evidence is current, not carried forward, which is exactly what matters given how quickly branch heads move.
The compass#21 retargeting is the meaningful change here. The previous stacking made the pipeline fix conditional on this thread resolving, which inverted the priority: a CI infrastructure fix should not gate on a feature gap that is explicitly deferred to SEA-1359. Disjoint file lists and a clean merge-tree make it safe to land independently, and that is the right call.
Thread state: the P1 framing stands, the fix location (SEA-1359 / cli.ts) is recorded, and nothing in this diff should change as a result. Keeping this open until the entrypoint lands and wires the call site is correct — closing it on a recorded dependency would hide the gap from the person landing compass#7.
Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.
There was a problem hiding this comment.
Reopened this thread. The ca0d933 force-push (rebase onto new main past #19's CI merge) flipped it to resolved (is_outdated:false, so not a stale-diff auto-resolve), which contradicts the deliberate intent recorded above — this P1 stays open until SEA-1359's entrypoint wires createCommsTools, which has not landed. Restoring the intended-open state so it stays visible to whoever lands SEA-1359. It does not gate merge (known-open; merge-with-known-P1 is the human gate call).
Greptile SummaryAdds the agent-side comms tool implementation and associated transport/schema exports.
Confidence Score: 3/5The PR is not ready to merge as a complete feature because the new comms tools still are not installed on any production agent path. The broker and tool implementations are exported and tested, but current code does not instantiate the broker or register the resulting tools; the only existing installation path applies tools received through a config control, leaving both new tools absent at runtime. Files Needing Attention: packages/compass-agent/src/comms.ts, packages/compass-agent/src/index.ts, packages/compass-agent/src/agent.ts
|
| Filename | Overview |
|---|---|
| packages/compass-agent/src/comms.ts | Implements the broker, schemas, tool calls, and transcript renderer; the previously reported production-registration gap remains outstanding. |
| packages/compass-agent/src/comms.test.ts | Adds broad tests for wire requests, parameter validation, rendering order, malformed fields, and transcript-forgery defenses. |
| packages/compass-agent/src/compassv1.ts | Re-exports the generated comms request, response, envelope, and schema types required by the tools. |
| packages/compass-agent/src/transport/index.ts | Updates documentation to identify the comms RPC as the transport consumed by the new broker. |
| packages/compass-agent/src/index.ts | Publicly exports the comms factory and broker, but this export alone does not resolve the outstanding registration finding. |
| packages/compass-agent/package.json | Adds ArkType 2.2.3 to align schema types with the agent SDK dependency graph. |
| bun.lock | Records the ArkType workspace dependency while retaining a single compatible Ark schema version. |
| go/internal/comms/harness_test.go | Expands test-harness documentation describing replay/live delivery discrimination and mutation checks. |
Reviews (2): Last reviewed commit: "fix(compass-agent): collapse a class of ..." | Re-trigger Greptile
…r socket Implements 848-T3: the agent-side `CommsBroker` and `createCommsTools`, exposing `comms_post_message` and `comms_list_messages` to a containerized agent. Both ride the per-container Unix socket to the Runner, which relays over `RelayCommsCall` to the Server, which attributes the call to the session's account in-process, fail-closed. Consumes the two legs already on main: `RelayCommsCall` on `runner.proto` and the Server hub handler in `go/internal/runnerhub`. `arktype` tracks the agent SDK's own version (2.2.3) rather than an exact pin, so `@ark/schema` dedupes to a single copy — two copies make the parameter schemas structurally incompatible with the SDK's tool types. Refs SEA-1355 Co-Authored-By: seal <noreply@sealedsecurity.com>
Design records stay in sealed (Matt, 2026-07-28). The record is already on sealed main via #848; carrying a copy here would fork a canonical document across two repos. The errata this branch had accumulated go to sealed as their own change. Refs SEA-1355 Co-Authored-By: seal <noreply@sealedsecurity.com>
Both are in the transcript renderer, and both are the class the file already defends elsewhere. custom_text was rendered raw while the question one line above it gets a newline collapse. It is participant free text from RespondToAsk and nothing on the Go path trims it or rejects a newline, so it is reachable today: a newline splits one marker line into two, the second unfenced and unmarked. at_unix_ms is an int64 and toISOString() throws past ±8.64e15 ms. The throw escaped execute, so one bad row failed the whole page and the model could read no message in the channel. It now degrades like every other attribute. Server-minted from a real clock today, so was id. Both tests fail against the unfixed renderer. Also from review, no behaviour change: - The design citation named no repo; the record lives in sealed. - The header claimed the RPC owns cancellation. It does not: execute's AbortSignal is not forwarded, so an aborted turn still posts. Stated plainly rather than claimed away; whether to plumb it is on the PR. - createCommsTools has no non-test caller and the comment asserted a registration site absent from this repo. - The schema test asserted a bare toJsonSchema() the harness never calls; it now pins the degraded output the model actually sees, and reddens if the narrow ever starts emitting. - The fence normalizer rewrote any 8-hex run, not the fence. - approval tiers and schema wiring were unasserted. Refs SEA-1355 Co-Authored-By: seal <noreply@sealedsecurity.com>
Round 2 caught that the previous commit fixed one of three untrusted values on the same rendered line, and reproduced the identical forgery through the other two. An option label has wider reach than custom_text: it is caller-supplied on the ask and stored verbatim — validateAskQuestions checks question count, id uniqueness and the recommended index, never the label — so any member who can post can plant a newline, where custom_text needs a pending ask to answer. The `?? id` fallback is the same shape. So the collapse moves to where the values merge rather than sitting on whichever field was noticed. `flat` sits next to `attr`: attr guards a tag attribute, flat guards a marker line. A value added to that line later cannot arrive raw by omission. Also from round 2: the fence normalizer in the no-renderable-content test was made a tautology by the previous commit. Normalizing both strings by ONE of their fences leaves the record tag unequal, so not.toBe passed regardless of the marker — unfencing the marker entirely left the test green. Each string is now normalized by its own fence and the marker is asserted directly; the same mutation reddens. Both fixes verified by mutation, not inspection: reverting the label collapse reddens exactly the label test, and unfencing the marker reddens the marker test. Refs SEA-1355 Co-Authored-By: seal <noreply@sealedsecurity.com>
…dges The ±8.64e15 guard was the range limit, but `attr` rejects a shape the guard admits: past year 9999 the ISO form is the expanded-year `+010000-01-01T…`, whose leading `+` fails `/^[\w.:-]+$/`. So a value the guard passed was degraded a second time one line later, by a different mechanism, while the comment above claimed the timestamp passes the shape test unchanged. Two overlapping degradations where the prose described none. The bound is now year 9999 on both sides, so the renderer degrades a timestamp in exactly one place and the comment is true as written. The test tabled the boundary rather than one value past the positive edge. One fixture cannot see a dropped lower bound — verified by mutation: removing it reddens only the below-year-1 row, which the single-fixture version passed. Refs SEA-1355 Co-Authored-By: seal <noreply@sealedsecurity.com>
`flat` collapsed `\s*\n\s*`, so a lone CR, U+2028, U+2029, VT, FF and NEL all rode through it into a marker line - the exact forgery it exists to stop, spelled six ways it did not know about. Its sibling `attr` argues the reason in the same file: an escape must enumerate what to escape, and every missed spelling is a live hole. Widening the list to those six still admits every other C0 control, including ESC, which in a terminal starts an ANSI sequence rather than being a character. So the guard now constrains a class - `Cc`/`Zl`/`Zp` plus whitespace - and a break spelled in some encoding nobody considered is covered without being named. The error path carried its own copy of the old regex, and kept the LF-only spelling. It now calls `flat`; two guards against one threat drift apart silently, and the weaker one is the one nobody re-reads. Also fixes a test that could not fail. The `?? id` fallback test asserted only a count of lines containing the FENCED marker, while the forgery it injects is unfenced - so the forged line was never counted and the assertion held either way. It now carries the `toContain` its two sibling tests pair with the count. Verified by mutation: dropping `flat` on the fallback arm reddens that test where it previously stayed green, and reverting `flat` to the LF-only form reddens 11 rows - every spelling except LF and CRLF, which the old guard did handle. Refs SEA-1355 Co-Authored-By: seal <noreply@sealedsecurity.com>
3cb5c65 to
ca0d933
Compare

Implements 848-T3, the last leg of SEA-1355: the agent-side
CommsBrokerandcreateCommsTools, exposingcomms_post_messageandcomms_list_messagesto a containerized agent.Both tools ride the per-container Unix socket to the Runner, which relays over
RelayCommsCallto the Server, which attributes the call to the session's account in-process, fail-closed. An agent-initiated comms call never reaches a network door.Consumes the two legs already on
main:RelayCommsCallonrunner.proto, and the Server hub handler ingo/internal/runnerhub.Design record stays in sealed — it is already on sealed
mainvia #848, and errata found while implementing this are filed separately as sealed#999. A copy here would fork a canonical document across two repos.Ported from the monorepo
Retargets
sealedsecurity/sealed#993, opened againstoss/compassafter the pivot had already moved Compass here. #993 is closed unmerged with a pointer.One change the port required
arktypetracks the agent SDK's own version (2.2.3) rather than the exact2.2.2pin the monorepo carried.pi-coding-agent@16.5.2depends onarktype@2.2.3, and an exact2.2.2resolved two copies of@ark/schema(0.56.1 and 0.56.2), making the tool parameter schemas structurally incompatible with the SDK's own tool types —tscfailed withTypes have separate declarations of a private property 'requiresInvalidDateCheck'. Matching the SDK dedupes to one copy.Worth knowing for any other package here adding
arktype: match whatever the SDK resolves, do not pin exact.Review: three rounds, and two real defects in the renderer
Both were in the transcript renderer, both reachable by an untrusted participant, and both are the class the file already defends elsewhere — which is what makes them worth naming rather than quietly fixing.
A newline forged a second record. The renderer collapsed newlines in
q.questionbut not in the values beside it on the same line. Round 1 foundcustom_text; round 2 found the fix was too narrow — an optionlabelis caller-supplied on the ask and stored verbatim (validateAskQuestionschecks question count, id uniqueness and therecommendedindex, never the label), so any member who can post can plant one, wherecustom_textat least needs a pending ask to answer. The collapse now happens where the values merge (flat, besideattr), so a value added to that line later cannot arrive raw by omission.One bad timestamp failed the whole page.
at_unix_msis an int64 andtoISOString()throws past ±8.64e15 ms — the throw escapedexecute, so a single out-of-range row meant the model could read no message in the channel, a strictly wider blast radius than the degraded attributes the file already handles. Round 2 then caught that the guard's bound admitted valuesattrdegrades anyway (past year 9999 the ISO form is+010000-…, whose leading+fails the shape test), so the bound is now year 9999 on both sides and the renderer degrades a timestamp in exactly one place.Neither is reachable today — every server write path derives
atfrom a real clock, and ids are server-minted. That is precisely the file's own standard: "a boundary that holds by accident is not a boundary."Round 2 also found a fix of mine had made a test weaker: normalizing both strings by one fence left the record tag carrying the inequality, so
not.toBepassed even with the marker completely unfenced. Own-fence normalization plus explicit marker assertions; the unfencing mutation now reddens.Round 3 found the guard was a list, not a class.
flatcollapsed\s*\n\s*— so a lone CR, U+2028, U+2029, VT, FF and NEL all rode straight through into a marker line. Six spellings of the exact forgery it exists to stop. The file already argues why, inattr's comment one screen above: "An escape must enumerate what to escape and every missed spelling is a live hole."flatwas that enumerating guard, sitting beside the comment warning against it.The reviewer proposed widening the list to those six. I checked that fix and it leaves seven more holes — every other C0 control, including ESC, which in a terminal starts an ANSI sequence rather than being a character:
So the guard now constrains the class (
Cc/Zl/Zpplus whitespace) rather than naming members. The error path had its own copy of the old regex and would have kept the LF-only spelling — it now callsflat, because two guards against one threat drift apart silently and the weaker one is the one nobody re-reads.And round 3 found a test of mine that could not fail. The
?? idfallback test — added by the round-2 fix, specifically to defend that arm — asserted only a count of lines containing the fenced marker. The forgery it injects is unfenced, so it was never counted and the assertion held whether the forgery landed or not. The reviewer proved it with a mutation I had not run: dropflaton that arm, and only the label test reddens. It now carries thetoContainits two siblings pair with the count.That is the second time this review found a test that passes for a reason unrelated to what it names — and the reason it was found both times is mutation, not reading.
Also corrected, no behaviour change: the header claimed the RPC owns cancellation (it does not —
execute'sAbortSignalis not forwarded, so an aborted turn still posts; stated plainly rather than claimed away),createCommsToolshad no non-test caller while the comment asserted a registration site absent from this repo, the design citation named no repo, and the schema test asserted a baretoJsonSchema()the harness never calls.Verification
Status:
locally-verified, neverCI-verified. This PR has ZERO check-runs — not a passing gate, no gate at all (gh pr checks 15→no checks reported). The repo has no workflows (git ls-tree origin/main .github/workflows/is empty) and no ruleset requiring any (gh api .../rulesets→ 0, branch protection → 404).That second absence is the load-bearing one, and it is why I am restating this section rather than leaving my earlier "there is no CI here" note. An empty required-checks set does not read as absent to anything downstream — it reads as satisfied.
tern'srequired_checksanswersall_passing: trueon this PR, correctly, because every required check passed and there are none. A reader asking "is it ready" gets a green verdict from a question nobody was asked.So the distinction that matters here is not "did a gate pass" but who produced the evidence:
locally-verified— I ran the gate on my machine and state what it covered. Everything below.CI-verified— the repository ran it. Nothing in this PR can claim that.The defect is not the quality of the local run; it is that it is evidence I produced about my own work, which is the one property CI exists to supply and no amount of local rigour substitutes for. Treat the section below as testimony, and expect this PR to meet a pipeline for the first time when one lands.
Scope: what this diff touches, and what covers it
packages/compass-agent/src/{comms,comms.test,compassv1,index}.ts,transport/index.tscompass-agent:cipackages/compass-agent/package.jsoncompass-agent:ci(viaroot:install)go/internal/comms/harness_test.gocompass-go:test— not incompass-agent:cibun.lockroot:installonly; no task lints itcompass-agent:ciisdeps: ['typecheck', 'test']— no lint task, and no Go coverage. Quoting it alone would be a true claim about the wrong scope, so the Go leg and biome are run separately.moon run compass-agent:ci --force
Zero
cachedmarkers — the run executed. (A cached run reports(cached, <hash>); that is the structural discriminator, not elapsed time.)bunx biome check packages/compass-agent/src/→Checked 19 files. No fixes applied.Every new test verified by mutation, not inspection
A test that passes with the fix reverted defends nothing, so each was run against a broken renderer:
The last two are the original defects reproducing exactly. The first line is the round-3 correction: that mutation used to redden only the label test, which is how the reviewer proved the
?? idtest was passing for the wrong reason.bun testreports 185 pass / 0 fail (was 172; +13 from the two terminator tables).Go leg (harness_test.go is pgtest-tagged, so it is outside the default gate)
100.6s is the discriminator:
go testprintsokfor a fully-skipped tagged suite, so elapsed time is the only thing separating ran from skipped.Ran against the pipeline
compass#19will land, not just my package's gate#19adds two jobs. Rather than wait for first contact when it merges, I ran both commands locally on this branch.gate—moon run :ci(repo-wide, notcompass-agent:ci):pgtest—go test -tags pgtest -race ./...against the exact image#19pins (postgres:16-alpine). My fourharness_test.gotests had never been run under-race:I also applied
#19's own two assertions rather than readingok:grep -c 'skipping real-Postgres test'→ 0 (the harness skips silently without a database, which is the job's stated failure condition), andgrep -c '^--- PASS'→ 4. ZeroDATA RACE.So this PR should not red on first contact. Two things in the module do, and both reproduce on clean
mainwith nothing of mine applied:TestServeShutdownWithLiveCommsSubscriberReturnsCleanTestIntegrationProvisionStartRelayToStoreAndBusThe first is a genuine bug in my lane's contract and worth naming here: the test opens
SubscribeComms(SinceSeq: 0)and treats the first frame as its seeded event, but asince_seq=0subscribe now receivescommsSnapshotBoundaryfirst (subscribe.go:226). I probed it rather than inferring —payload = <nil>, seq = 0is the boundary frame. The test fails before reaching the shutdown drain it exists to exercise, so that contract is currently unverified onmain.Open questions for Matt
1. Should an aborted turn cancel an in-flight post?
executereceives anAbortSignalthat this implementation does not forward, andCommsTransport.commsis the only method on that interface without anoptionsparameter — its siblingpostConversationFrameplumbs one. So an aborted turn still posts.Assumption I proceeded under (overnight, conservative): leave the behaviour as shipped and correct the comment that claimed otherwise, rather than change a wire path under review. The idempotency key means a re-issue after an abort dedupes rather than double-posting. Your call: plumb the signal end to end, or ratify "a post completes once issued" as the contract.
2. RULED, and half of it is now another lane's work. Greptile filed a P1 on this file with a sharper framing than mine — I wrote "no non-test caller," a property of the source; they wrote that
comms_post_message/comms_list_messagesare absent from the running agent's tool list. Same fact, but only one of them says the feature does not work, and I had been reading my own phrasing for three review rounds.Chased to root:
grep 'new CompassAgent('matchesagent.test.tsonly — nothing boots a Compass agent anywhere in this repo. That is SEA-1359's stated subject ("the Runner execs acompass-agentcommand that does not exist"), andagent.ts:44-49names the container entrypoint as the registration seam by design. So this is a dependency, not a defect in this diff: wiring contract recorded on SEA-1359, dependency recorded on SEA-1355, no duplicate filed. The P1 thread is deliberately left unresolved — the gap is real until that entrypoint exists.On the adjacent fork, Matt ruled: natives survive.
setTools(agent.ts:147-148) assigns rather than merges, so aconfigcontrol carrying tools would silently revoke an agent's ability to talk. His call: comms is not a grantable capability, and restriction belongs at the server — D9 visibility and channel membership already decide what an account may post, enforced in SQL — not in which tools a control frame happens to list. Filed as SEA-1532 and handed to compass-agent, sinceagent.tsis their file and is not in this diff.Nothing in this PR changes as a result.
createCommsToolsis correct as shipped; the registration and the merge-not-assign fix both land in lanes that own those files.3.
SEA-1529—control.ts:42carries a dead#deriveAskreference in source, onmain, from the port commit (e7277f7, compass#3). Neithercontrol.tsnormapping.tsis in this diff; filed and owned rather than folded into a feature PR.Refs SEA-1355