Skip to content

feat(runtime-host): host sandbox boundary interactions - #1935

Merged
M4n5ter merged 2 commits into
apache:mainfrom
M4n5ter:feat/runtime-host-sandbox-boundary-interactions
Aug 3, 2026
Merged

feat(runtime-host): host sandbox boundary interactions#1935
M4n5ter merged 2 commits into
apache:mainfrom
M4n5ter:feat/runtime-host-sandbox-boundary-interactions

Conversation

@M4n5ter

@M4n5ter M4n5ter commented Aug 3, 2026

Copy link
Copy Markdown
Member
English

Summary

  • Expose session sandbox-boundary requests through the Runtime Host Interaction protocol so any connected client can query and answer them.
  • Keep SessionStore as the canonical boundary commit point while the Host owns admission, exact-Run continuation capture, first-winner settlement, and stop/restart closure.
  • Create hosted boundary rows only after snapshot-capacity preflight, preventing rejected admissions from leaving orphaned pending requests.
  • Route decisions raised by Agent Graph operator Sessions back to the durable root Session before waking the graph supervisor.

Verification

  • npm run build:test
  • npm run lint
  • npm run format:check
  • Runtime Interaction and sandbox-boundary suites
  • Runtime Host Interaction, protocol, and graph-root routing suites
  • Two-client UDS settlement, exact-Run resume, and Host-restart round trip

Part of #1167.

中文

概要

  • 通过 Runtime Host Interaction 协议暴露 Session sandbox-boundary 请求,使任意已连接客户端都能查询并回答。
  • 继续由 SessionStore 作为 boundary 的唯一 canonical commit point;Host 负责准入、精确 Run continuation 捕获、first-winner 结算,以及停止/重启关闭。
  • Hosted boundary 仅在 snapshot 容量预检通过后创建,避免准入拒绝遗留无主的 pending 请求。
  • Agent Graph operator Session 发起的请求会先映射回持久化 root Session,再唤醒 graph supervisor。

验证

  • npm run build:test
  • npm run lint
  • npm run format:check
  • Runtime Interaction 与 sandbox-boundary 测试
  • Runtime Host Interaction、协议与 graph root 路由测试
  • 双客户端 UDS 结算、精确 Run 恢复与 Host 重启往返测试

属于 #1167 的一部分。

@M4n5ter
M4n5ter marked this pull request as ready for review August 3, 2026 07:32
@M4n5ter
M4n5ter force-pushed the feat/runtime-host-sandbox-boundary-interactions branch from 8ab4668 to edfcc67 Compare August 3, 2026 08:41
@M4n5ter
M4n5ter merged commit 34805cf into apache:main Aug 3, 2026
10 checks passed
@M4n5ter
M4n5ter deleted the feat/runtime-host-sandbox-boundary-interactions branch August 3, 2026 08:47
hqhq1025 added a commit to hqhq1025/maka-agent that referenced this pull request Aug 3, 2026
Three of the sentences this branch rewrote still handed the model a move
it could not make, and the tests could not tell, because they call
tool.impl with a hand-built context: nothing there knows which agent
receives the message, or whether a state survives the turn.

The shell-slot refusals hedged on the wrong thing. "Stop one of yours
with StopBackgroundTask if you started any" hedges on owning a run; the
gap is owning the tool. Built with the real buildChildAgentTools over a
parent set carrying the shell tools, a child agent receives Read, Glob,
Grep, WebSearch, Write, Edit, Bash and ArchiveRead — Bash yes,
StopBackgroundTask and WriteStdin no, because
buildToolsForAgentDefinition is a strict name allowlist. So the caller
that most often reaches the manager-wide cap is precisely the one that
would go looking for a schema entry it does not have. Both sentences now
describe the move instead of naming a tool, and the PTY one leads with
dropping `pty`, which is a parameter every caller already has. The test
asks the real builder which names a child never receives and asserts the
refusal contains none of them, so the next sentence that reaches for one
turns it red.

goal_changed invited a retry that cannot succeed. registration.
observedControlLease is written once at beginObservedTurn and refreshed
only by a mutation that succeeds, so once the lease has moved the turn
declines the same way until it ends. Driven against the real coordinator
— one turn registers, a second arms a goal and clears it — GoalSet
declines goal_changed on the first call and on the fourth, and no call
available to that turn changes the answer. The GoalSet arm of
goal_already_armed is the same shape: reachable only once the armed goal
has left `active`, and by then the controlLease cannot be released,
because releasing it needs a mutation the moved lease also declines.
Both now end by closing the retry. GoalStatus is still offered, because
reading is still worth doing; it is the last step rather than a step
back to the call that just failed.

The agent_swarm resume refusal prescribed spawning. ToolRuntime's
buildChildAgentContext returns {} wholesale when getCurrentRunId() is
falsy, so spawn and resume leave together, and "drop resume_run_ids and
send that work as new items instead" walks the model into the spawn
refusal, which tells it retrying fails the same way. The advice now asks
whether spawning is available before prescribing it — an embedder can
supply one without the other, so it is a real question — and the test
follows the advice rather than reading it.

Also restores what an operator lost. The rejected background-task ref,
and the three capability names the agent-swarm and subagent refusals
stopped mentioning, travel as the Error cause the way builtin-tools.ts
already carries its worker-protocol violations: out of the transcript,
in every stack. And "This is an internal failure, not a problem with
your arguments" could have been deleted from all five filesystem
messages without turning a test red; now it cannot.

Rebased onto main. The tool-runtime.ts conflict is apache#1935's hosted-run
routing against this branch's message: upstream's !hostedRun guard is
kept, so hosted runs still route to the host instead of throwing, and
only the message changes. The comment there now records that this guard,
unlike the one in sandbox-boundary-tool.ts, does fire in production —
the CLI supplies the sandbox store callbacks only on the tui surface.
The two branches that are genuinely unreachable through ToolRuntime say
so where they are.
hqhq1025 added a commit to hqhq1025/maka-agent that referenced this pull request Aug 3, 2026
Three of the sentences this branch rewrote still handed the model a move
it could not make, and the tests could not tell, because they call
tool.impl with a hand-built context: nothing there knows which agent
receives the message, or whether a state survives the turn.

The shell-slot refusals hedged on the wrong thing. "Stop one of yours
with StopBackgroundTask if you started any" hedges on owning a run; the
gap is owning the tool. Built with the real buildChildAgentTools over a
parent set carrying the shell tools, a child agent receives Read, Glob,
Grep, WebSearch, Write, Edit, Bash and ArchiveRead — Bash yes,
StopBackgroundTask and WriteStdin no, because
buildToolsForAgentDefinition is a strict name allowlist. So the caller
that most often reaches the manager-wide cap is precisely the one that
would go looking for a schema entry it does not have. Both sentences now
describe the move instead of naming a tool, and the PTY one leads with
dropping `pty`, which is a parameter every caller already has. The test
asks the real builder which names a child never receives and asserts the
refusal contains none of them, so the next sentence that reaches for one
turns it red.

goal_changed invited a retry that cannot succeed. registration.
observedControlLease is written once at beginObservedTurn and refreshed
only by a mutation that succeeds, so once the lease has moved the turn
declines the same way until it ends. Driven against the real coordinator
— one turn registers, a second arms a goal and clears it — GoalSet
declines goal_changed on the first call and on the fourth, and no call
available to that turn changes the answer. The GoalSet arm of
goal_already_armed is the same shape: reachable only once the armed goal
has left `active`, and by then the controlLease cannot be released,
because releasing it needs a mutation the moved lease also declines.
Both now end by closing the retry. GoalStatus is still offered, because
reading is still worth doing; it is the last step rather than a step
back to the call that just failed.

The agent_swarm resume refusal prescribed spawning. ToolRuntime's
buildChildAgentContext returns {} wholesale when getCurrentRunId() is
falsy, so spawn and resume leave together, and "drop resume_run_ids and
send that work as new items instead" walks the model into the spawn
refusal, which tells it retrying fails the same way. The advice now asks
whether spawning is available before prescribing it — an embedder can
supply one without the other, so it is a real question — and the test
follows the advice rather than reading it.

Also restores what an operator lost. The rejected background-task ref,
and the three capability names the agent-swarm and subagent refusals
stopped mentioning, travel as the Error cause the way builtin-tools.ts
already carries its worker-protocol violations: out of the transcript,
in every stack. And "This is an internal failure, not a problem with
your arguments" could have been deleted from all five filesystem
messages without turning a test red; now it cannot.

Rebased onto main. The tool-runtime.ts conflict is apache#1935's hosted-run
routing against this branch's message: upstream's !hostedRun guard is
kept, so hosted runs still route to the host instead of throwing, and
only the message changes. The comment there now records that this guard,
unlike the one in sandbox-boundary-tool.ts, does fire in production —
the CLI supplies the sandbox store callbacks only on the tui surface.
The two branches that are genuinely unreachable through ToolRuntime say
so where they are.
hqhq1025 added a commit that referenced this pull request Aug 3, 2026
…ng (#1885)

* fix: tell a refused tool call what to do next, not just what went wrong

A refusal that names only its cause is a refusal a model re-sends. That was
measured, not assumed: across recorded runs, refusals with no next move came
back as identical retries, and refusals that named an internal noun the model
has no verb for came back as retries of the same call with different
arguments.

Every sentence changed here answers "so what do I do now":

- `agent_spawn` / `agent_list` / `agent_output` said the capability "is
  unavailable in this runtime context". A model cannot make a runtime context
  available. They now say retrying will fail the same way and to do the task
  with the tools already in hand.
- The four Goal refusals said "this turn no longer owns Goal activation".
  Ownership is a host state machine label. They now name `GoalStatus` as the
  call that shows what the goal actually is.
- `agent_output` locator rejections said "its matching identity fields",
  leaving the model to guess which of four optional id fields to add. They now
  name the fields for that locator.
- `Edit` and `Write` reported an internal filesystem-identity mismatch through
  the argument-error path, so it read as a complaint about `old_string`. They
  now say the file is unchanged, that the arguments are not the problem, and
  what to do.
- A malformed background-task ref got its own text echoed back. It now gets
  the canonical form.
- A full shell or PTY slot did not say what frees one. It names
  `StopBackgroundTask`.

The test walks each refusal and asserts on the sentence a model would read.
Three of its cases need `node:sqlite` and are skipped on runtimes without it.

* test: catch the existing assertions up with the sentences they read

CI caught nine tests pinned to wording this change replaces. Every one of them
asserted the half of the old sentence that had to go — the host noun the model
could not act on — so they had to move rather than be relaxed.

They now pin what the new sentence is for:

- the six Goal declines assert `Call GoalStatus`, the call that shows what the
  goal actually is, instead of `no longer owns Goal activation`
- `agent_swarm` asserts it names the tool and what to do instead, rather than
  `spawnChildSession capability is unavailable`
- the two capacity refusals assert they name `StopBackgroundTask`, which is
  the thing that frees a slot and which the old wording never mentioned

Relaxing the patterns instead would have left tests that pass whatever the
refusal says, which is how a sentence stops being anybody's contract.

* fix: stop the new refusals from asserting and prescribing what they cannot

Review found that three of these rewrites traded an opaque sentence for a
confident wrong one. A refusal is read by a model, so the cost of a claim it
cannot support is a decision made on a false picture.

The four Goal declines are the worst of it. They asserted one cause — "the
session goal changed while this turn was running" — and prescribed a retry.
But the gate returns undefined for five reasons and only one of them is a race.
A turn that was never registered under a Goal boundary declines permanently:
every turn started with goalBoundary 'none', which is the graph supervisor and
both session-execution IPC paths, and every turn whose session was closed and
removed. The PR's own test file demonstrates the loop. In "GoalSet after
permanent removal" the session is gone, so GoalSet says call GoalStatus and
retry "only if there is still no active goal", GoalStatus says "No goal set for
this session", the condition holds, the model retries, and it receives the
identical refusal. Forever. The old text was opaque but it asked for nothing.

So the coordinator now says why. `activationStanding` and `mutationStanding`
hold the same predicates the two mutators use — one source of truth, not a
second copy that can drift — and each names a cause. GoalStatus is prescribed
where reading can change what happens next, and where it cannot the sentence
says so and asks the model to report instead. One of the five turned out to be
misnamed while I was writing it: the lane is looked up from the map before it
is checked for currency, so that branch can only fire when the coordinator is
disposed, and it is called that now rather than pretending a later turn took
over.

`internalFilesystemFailure` asserted disk state it never looked at. That branch
fires when the worker returns a well-formed result of the wrong operation; real
failures throw. A worker that writes the file and then labels the result 'glob'
produced "nothing was written to disk", or for Edit "the file is unchanged … a
different old_string will not help", and the model went on from there. It is
now two helpers. A write says Maka cannot tell whether the change landed and
sends the model to read the file. A read says no result came back — and says
what that does not mean, because "Grep could not be completed inside Maka, so
no matches were produced" reads as a search that ran and found nothing, and a
model that takes it that way concludes the pattern is absent from the
repository. Glob had the same shape and is fixed with it.

The capacity message promised a move that can be unavailable: the reserved-run
counters are manager-wide while StopBackgroundTask takes a session-scoped ref,
so with several sessions live the one that hits the cap may own none of the
runs holding it. It now offers that move as a condition and names waiting,
which always works. The review also flagged the printed number as the cap
rather than the reserved count; it is both — the throw fires at `reserved >=
max` and reserved is only ever incremented after that check passes, so the two
are equal here. Left as is.

Not changed, but no longer covered silently: the AskUserQuestion and
request_sandbox_boundary guards cannot fire in production. ToolRuntime injects
both callbacks unconditionally, unlike listChildAgents and readChildAgentOutput
beside them, which are spread only when present. Availability is decided when
the tool set is assembled — the CLI adds both only on the TUI surface. The
guards stay because the context type declares the callbacks optional and an
embedder may build one without them, and the tests now say that is the contract
they cover rather than implying a reachable path.

Every new assertion was negative-controlled. Collapsing the Goal causes back
into one retry message turns fifteen of them red, including the two real-
coordinator scenarios that reproduce the loop.

* fix: stop the refusals prescribing moves the caller does not have

Five of the sentences this branch rewrote still asserted or prescribed
something the code cannot back.

The filesystem-worker refusals ended "use Bash to do the same work". A
local_read child is given Read, Glob and Grep and nothing else, so the
caller most likely to be running a bare Grep was handed an instruction it
cannot carry out. The shell is now offered on a condition the model can
check for itself, and the sentence ends on a move every caller has. The
worker-protocol violation still reaches an operator: it travels as the
Error cause, out of the model's sight and in every stack.

coordinator_disposed could not be produced. dispose() sets the flag and
clears the lane map in one step, so the lane lookup above the isCurrent
check already failed and the turn was told it "does not run under the
session goal boundary" — false about a turn that had armed the goal it
was trying to clear. Shutdown is now asked about first, and the carefully
worded shutdown sentence can be shown.

GoalResume goes through the activation gate, so it could be told it had
"already armed a goal" and "cannot arm a second one" in reply to a call
that asked to arm nothing. The advice now knows which tool it answers.
Nor is "already armed" the right noun: beginObservedTurn binds the lease
by itself when a goal is already active, and that turn armed nothing.

"Goal authority is unavailable." reads as a passing condition. Its only
producer is beginDrain, which is one-way, so it now says the door does
not reopen.

request_sandbox_boundary was rewritten on a path ToolRuntime cannot
reach, because ToolRuntime injects the callback unconditionally; the
sentence a model actually gets was left alone. Both now share one
constant. The retry branch in agent_swarm and the ref message on the
Read({ref}) path, which is where a ref is most likely mistyped, get the
same treatment.

Tests: the fake coordinator answered both authorization gates with the
same reason, so a tool wired to the wrong gate stayed green. The two
gates now answer separately, and every decline cause is produced by the
real coordinator.

* fix: stop the refusals naming a tool the reader may not have

Three of the sentences this branch rewrote still handed the model a move
it could not make, and the tests could not tell, because they call
tool.impl with a hand-built context: nothing there knows which agent
receives the message, or whether a state survives the turn.

The shell-slot refusals hedged on the wrong thing. "Stop one of yours
with StopBackgroundTask if you started any" hedges on owning a run; the
gap is owning the tool. Built with the real buildChildAgentTools over a
parent set carrying the shell tools, a child agent receives Read, Glob,
Grep, WebSearch, Write, Edit, Bash and ArchiveRead — Bash yes,
StopBackgroundTask and WriteStdin no, because
buildToolsForAgentDefinition is a strict name allowlist. So the caller
that most often reaches the manager-wide cap is precisely the one that
would go looking for a schema entry it does not have. Both sentences now
describe the move instead of naming a tool, and the PTY one leads with
dropping `pty`, which is a parameter every caller already has. The test
asks the real builder which names a child never receives and asserts the
refusal contains none of them, so the next sentence that reaches for one
turns it red.

goal_changed invited a retry that cannot succeed. registration.
observedControlLease is written once at beginObservedTurn and refreshed
only by a mutation that succeeds, so once the lease has moved the turn
declines the same way until it ends. Driven against the real coordinator
— one turn registers, a second arms a goal and clears it — GoalSet
declines goal_changed on the first call and on the fourth, and no call
available to that turn changes the answer. The GoalSet arm of
goal_already_armed is the same shape: reachable only once the armed goal
has left `active`, and by then the controlLease cannot be released,
because releasing it needs a mutation the moved lease also declines.
Both now end by closing the retry. GoalStatus is still offered, because
reading is still worth doing; it is the last step rather than a step
back to the call that just failed.

The agent_swarm resume refusal prescribed spawning. ToolRuntime's
buildChildAgentContext returns {} wholesale when getCurrentRunId() is
falsy, so spawn and resume leave together, and "drop resume_run_ids and
send that work as new items instead" walks the model into the spawn
refusal, which tells it retrying fails the same way. The advice now asks
whether spawning is available before prescribing it — an embedder can
supply one without the other, so it is a real question — and the test
follows the advice rather than reading it.

Also restores what an operator lost. The rejected background-task ref,
and the three capability names the agent-swarm and subagent refusals
stopped mentioning, travel as the Error cause the way builtin-tools.ts
already carries its worker-protocol violations: out of the transcript,
in every stack. And "This is an internal failure, not a problem with
your arguments" could have been deleted from all five filesystem
messages without turning a test red; now it cannot.

Rebased onto main. The tool-runtime.ts conflict is #1935's hosted-run
routing against this branch's message: upstream's !hostedRun guard is
kept, so hosted runs still route to the host instead of throwing, and
only the message changes. The comment there now records that this guard,
unlike the one in sandbox-boundary-tool.ts, does fire in production —
the CLI supplies the sandbox store callbacks only on the tui surface.
The two branches that are genuinely unreachable through ToolRuntime say
so where they are.
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.

1 participant