Compare commits

...
Author SHA1 Message Date
Ryder Sun 11a25df584 release: prepare Meldwork V1.0.6
What changed
- Package version 0.1.5 -> 0.1.6 in desktop and frontend (package.json and the
  lock files).
- CHANGELOG entry for 0.1.6 (Meldwork-V1.0.6 prerelease).

Why
V1.0.5 was published on 2026-09-17 from the tag Meldwork-V1.0.5 (bb55fad).
Everything after it, 38 commits of Agent reliability, group conversation and
interface work, is version 1.0.6. This line was developed under the name
feature/1.0.5 by mistake. It is feature/1.0.6 now, and feature/1.0.5 is back at
the published V1.0.5.

Checks
- Only version fields and the changelog change, and no test pins the version.
- The code under test is that of ce995cb: frontend 357/357, desktop 1603/1603,
  deterministic evaluation 6 cases / 18 results.
2026-10-17 19:09:45 +08:00
Ryder Sun ce995cb93d feat: refresh the app UI and surface what needs the user
The chat page (header, messages, composer, run status and the animated empty
state) and the modal dialogs are deliberately left as they were before this
refresh: those files are byte-identical to 17c57f5.

What changed
- Design tokens: an 8-step type scale (readable text no smaller than 12px
  outside badges), semantic success/warning/danger/info colors for both themes,
  AA-contrast muted and accent text, a focus-ring token, elevation, layer and
  motion tokens, and a dark backdrop that is darker than the workspace card.
- Startup is no longer blocked by Agent detection. The full-screen WebGL overlay
  and the physics loader are removed (three is dropped from the dependencies);
  the sidebar, home page and composer show "Detecting local Agents" instead, and
  an empty Agent list is not reported as a setup problem while detection runs.
- Needs your decision: conversations waiting on a permission or decision are
  marked in the sidebar (rows, Agent icons, collapsed switcher) and listed on the
  home page. Group runs that finish while you are elsewhere get the same finished
  mark direct chats already had. Collapsed sidebar sections show a count.
- Settings: a new General tab (language, appearance: System/Light/Dark). The
  theme now follows the OS until one is chosen; it used to freeze on first
  launch. Agent cards explain why an installed Agent is unavailable (installed
  version, verified range, failed check) with a Re-detect action. Cloud
  connections ask before deleting.
- Gemini: a detected Gemini CLI gets its own logo. It stays out of the built-in
  list so nobody sees a "Not installed" card for an Agent Meldwork cannot install.
- Toasts: the duration grows with the message length and pauses on hover; errors
  use the alert role.
- Onboarding: a Skip button and arrow-key navigation.
- Unsent text is kept per conversation while the app is open.
- Copy: engineering terms (circuit breaker, hard budget, delivery owner) are
  gone from user-facing status text.
- Status colors, focus ring and the run-details waiting state use the new tokens.

Why
Small text and low-contrast grey carried the most important state (Agent
availability, time, hints); the brand color meant ready, done, running and
warning at once; and the app could not be used until local Agent detection
finished although history is local. This makes states legible and separable and
shows the ones that need the user.

Checks
- npm --prefix frontend test: 43 files, 357 tests passed
- npm --prefix frontend run build and build:desktop: passed
- Not verified in a running Electron window; the changed screens need a visual
  check in light and dark, Chinese and English.
2026-10-16 23:25:46 +08:00
Ryder Sun ef9933d571 fix: launch the WorkBuddy app's bundled CLI, which now ships only the lite bundle
What changed
- WorkBuddy is launched with CODEBUDDY_FORCE_LITE_WB_BUNDLE=1 when the install
  carries only dist/codebuddy-lite-wb.mjs, both for the capability probe
  (--help) and for real runs (the child environment). Installs that carry a
  regular bundle launch exactly as before, and other Agents are unaffected.
- Tests cover the lite-only layout, an install with a regular bundle, another
  Agent kind, an unreadable layout, a symlinked launcher, and the run
  environment.

Why
WorkBuddy 5.8.x updated its bundled CLI (2.161.0) to the lite bundle, which its
launcher loads only when that variable is set. Without it every headless call,
--help included, exits with MODULE_NOT_FOUND ("Cannot find module
'../dist/codebuddy'"). The capability probe therefore failed, WorkBuddy was
marked incompatible, and it disappeared from the usable Agent lists (new group,
@ menu). A CLI version outside the verified range does not block an Agent on its
own; only a failed capability probe does.

Checks
- npm --prefix desktop test: 1603 passed, 0 failed
- detectAgents() on this machine now reports WorkBuddy 2.161.0 as compatible; it
  was incompatible with a failed workbuddy-print probe before
2026-10-16 23:24:37 +08:00
Ryder Sun 17c57f57f8 test: a conversation stuck on an Agent must not stop another from using it
End-to-end version of the blocking fix, on the real LocalWorkspace: one direct
chat's turn never finishes, and a second direct chat with the same Agent has to
answer anyway while the first is still marked running. On the commit before the
fix this test times out, which is the symptom that was reported (the second
conversation sat on 'waiting for another Agent that is writing in the same
folder' until the first finished).
2026-10-16 16:10:03 +08:00
Ryder Sun 5054553a16 fix: the agent avatars no longer draw over the composer's @ menu
In a group chat the strip of member avatars above the composer (the run
status for the latest topic) was painted on top of the @ menu, covering its
'Agents' heading and the first rows.

The avatars overlap each other and order themselves with z-index 100, 99, 98
and so on. Nothing around them created a stacking context, so those numbers
competed with the page directly and beat the menu (z-index 4). The strip is
now its own stacking context, so the ordering only applies between the
avatars. The composer box gets the same treatment for the member chips (z-index
1-6): they were only contained while the box was focused, because the box's
focus transform happened to create a context.

Checked in a real renderer (headless Electron, the built frontend with a stub
bridge, a six-member group with a finished run and with a running run, composer
focused and not): the points of the menu covered by something else go from 36
to 0 in both states. jsdom has no layout, so composerStacking.spec.js pins the
CSS that keeps the numbers contained; two of its three cases fail without the
fix.

Checked with: npm --prefix frontend test (336 pass), build:desktop.
2026-10-16 16:02:48 +08:00
Ryder Sun e1500a73ef fix: conversations no longer wait for each other, even in the same folder
Sending to an Agent while the same Agent (or any Agent) was busy in another
conversation made the new conversation sit on 'Running' until the first one
finished. The cause was the writer lease, which was keyed by the real folder:
every writable conversation that used the same folder (and most keep the
default one) took turns, one at a time, across groups and direct chats.

9198bf6 had deliberately scoped the lease of direct chats to the chat so that
one writable chat could not block another Agent. 17cbd32 reverted that as a
regression and added a 'waiting for another Agent writing in the same folder'
notice. That was the wrong call: it made the product behave like a single
queue. This commit reverts 17cbd32 and goes one step further than 9198bf6:

- the writer lease is scoped to the conversation for groups as well as direct
  chats, so conversations never wait for each other; writers inside one
  conversation still take turns
- the waiting notice, the waitingForWorkspaceKinds snapshot field and the
  scheduler queue callbacks that only existed for the folder lock are removed
  with the revert
- the old rule 'group chats serialize writes across symlink aliases' is
  replaced by its opposite; new tests cover the same Agent in two direct chats
  and a group at once, group chats sharing a folder through a symlink alias,
  and that turns of one conversation share a lease scope while another
  conversation in the same folder does not
- architecture.md states the new rule and the trade-off

Trade-off: two Agents in different conversations can now edit the same files
at the same time, like two terminals in one directory. Output capture compares
.meldwork-output/ before and after a run, so when two conversations produce
deliverables in the same folder at the same time one may attach a file the
other wrote. No guard was added for that.

The new write-scheduling tests fail on the previous commit (two fail, one
never gets its second Agent started).

Checked with: local-workspace-write-scheduling (6 pass), run-scheduler,
run-coordinator, state, harness, npm --prefix frontend test (333 pass).
2026-10-16 15:10:35 +08:00
Ryder Sun 4a0915594f test: pin that unsupported ACP client requests get an error, never a success
fs/* and terminal/* requests from an Agent that never was offered those
capabilities, and unknown methods, must each receive an error response. The
behaviour held partly through parameter validation; the test now states the
contract (no result field, numeric error code, unknown methods get -32601).
2026-10-16 11:55:16 +08:00
Ryder Sun 5641969445 fix: switching between read-only and write no longer restarts the ACP process
The ACP runtime was identified by a signature that included the session mode
(plan/build for MiMo and OpenCode, default/accept_edits for Hermes). Group
discussions alternate between a read-only proposal and a write-enabled turn, so
the signature changed, the process was closed and the stored native session was
reported invalid, losing the conversation's context on every switch.

The mode is applied on every turn with session/set_mode, so it does not
identify a process. What genuinely needs another process (Hermes' and
OpenCode's permission policy is passed through the environment) is already part
of the signature through the environment, so those still start separately.
MiMo, whose arguments and environment are identical in both modes, now keeps
one process and one session across a read-only turn, a write turn and another
read-only turn.

The new test fails on the previous commit with LOCAL_AGENT_SESSION_INVALID.

Checked with: cli-native-acp-lifecycle (16 pass).
2026-10-16 11:51:00 +08:00
Ryder Sun 4b8ddd917f docs: describe the module layout and reliability rules, and fix dead links
architecture.md pointed at docs/flows.md, permissions.md, variables.md and
automation.md, which exist on no branch, and at three more that exist only on
main (this branch keeps /docs/ untracked). The related-documents list now
links only files tracked in the repository.

Adds a Code Map (what each invocation, prompt and auto-runner module owns and
where new behaviour should go) and the Reliability Rules the harness now
enforces, so the next change starts from the layout instead of the 6000-line
class.
2026-10-16 11:43:34 +08:00
Ryder Sun 0e4f1620e6 refactor: split the 6000-line auto-runner into files grouped by what they do
LocalWorkspaceAutoRunner was one class of 6000 lines. Its 99 largest methods now
live in eleven files named for their topic (recovery, collaboration,
natural-discussion, v4-context, v4-delivery, v4-synthesis, v4-results,
v4-phase, v4-discussion, task-graph, rounds) and are assembled onto the same
prototype the way the existing natural-decisions and v4-outcomes parts already
were. local-workspace-auto-runner.cjs keeps the constructor, start/resume and
run-level helpers (285 lines); the largest remaining file is 965 lines.

This is a move, not a rewrite. The split was generated by a script that copies
method source verbatim, and checked three ways before the tests ran:
- all 135 prototype methods exist with the same name and an identical source
  hash before and after (only the class constructor text differs, because the
  mixins are listed after it)
- a scope analysis finds no identifier that is read but not declared or
  imported in any of the 15 auto-runner files
- node --check on every file

The V2 round and V3 task-graph runs, which only exist to resume older runs,
are in their own files so they can be deleted together when that support ends.

Checked with: npm --prefix desktop run test:workspace (627 pass).
2026-10-16 11:43:34 +08:00
Ryder Sun 958d18152b fix: MiMo/OpenCode turns with tool calls are no longer cut short, and a CLI's own error is no longer masked by EPIPE
Two ways a CLI run ended early or with the wrong reason:

- MiMo and OpenCode print a step_finish after every model step. A step that
  ends with reason tool-calls is followed by more steps, but the transport
  terminated the process 250 ms after the first step_finish, so every turn
  that used tools came back as a partial answer. Only a final step now ends
  the run.
- Codex reads its prompt from stdin. A CLI that exits at once (expired
  sign-in, rejected flag) while a prompt larger than the pipe buffer is still
  being written produces EPIPE, which was reported as LOCAL_AGENT_SEND_FAILED
  instead of the CLI's own message. A closed pipe now waits for the process to
  finish and reports its stderr (so a sign-in failure is recognised as one);
  a process that stays alive without reading is still a send failure after
  5 seconds.

Both regressions fail on the previous commit.

Checked with: cli-adapters (74 pass incl. cli-process-support).
2026-10-16 11:29:28 +08:00
Ryder Sun bf49afcd92 feat: a member that is not ready no longer blocks messages to the whole group
Sending to everyone in a group failed with 'this local Agent is unavailable'
as soon as one member was not ready, so one broken CLI made the whole group
unusable. When the recipients are implicit (everyone, or auto discussion),
members that are not ready are now left out and the user is told which ones
were skipped. Agents the user picked or @mentioned explicitly are kept so the
send can still report them, and when nobody is ready the full list is kept so
the problem is not hidden.

Checked with: composerContext spec (3 new cases), npm --prefix frontend test
(333 pass).
2026-10-16 11:21:36 +08:00
Ryder Sun 49ed8aee0f fix: an Agent stays available through an inconclusive probe, and keeps its shell environment
Two ways a healthy Agent used to drop out of use without anything being wrong
with it:

- readiness: OpenClaw's 7 second status check, or any probe that crashes,
  marked the Agent unavailable at once, so it vanished from the picker until
  the next successful probe. A timeout is not a 'not signed in' answer. An
  Agent that was available now stays available through inconclusive results
  for up to 10 minutes; a definitive answer (signed out, incompatible version,
  a recorded auth failure) still applies immediately, and an Agent that was
  never available is not made available by it.
- login shell: when the shell used to capture PATH and API keys did not answer
  (slow rc file, cold boot), the bare app environment was used and cached for
  30 seconds, so Agents started without the user's PATH or keys and failed
  with sign-in errors. The last environment the shell produced is kept and
  reused; the timeout is 10 seconds; with no earlier success the failure is
  cached for 5 seconds instead of 30, measured from when the capture ends.

Both new tests fail on the previous commit.

Checked with: local-workspace-agent-catalog (22 pass), local-agent-readiness
(39), cli-network-environment, main-security (51).
2026-10-16 11:21:36 +08:00
Ryder Sun 462c1bd783 fix: an ACP turn no longer dies on traffic this client does not know
The ACP transport failed the whole turn on anything it did not recognise: a
banner line on stdout, a notification with an unknown method, a session update
kind added by a newer Agent, or an unknown request. Every Agent upgrade that
adds a message therefore broke Meldwork until it was updated, and a single
large tool result (over 1 MiB on one line) failed the turn.

- stdout lines that are not JSON objects are ignored
- unknown notifications and session updates that fail validation are dropped
  before they reach the SDK, which logs rejected messages with their params
- unknown requests are forwarded with their params stripped, so the Agent gets
  a proper 'method not found' answer instead of waiting, and nothing
  secret-bearing is logged
- the per-line limit is 4 MiB (still bounded; total input and reply limits are
  unchanged)
- truncated JSON that starts like a message, an invalid response payload and
  invalid permission requests still fail closed

The Kimi 'unsafe protocol input' test described the old behaviour; it now
checks that malformed JSON still fails closed and that unknown traffic carrying
a secret neither fails the turn nor reaches a log. The new tolerance test
fails on the previous commit (LOCAL_AGENT_PROCESS_FAILED).

Checked with: cli-adapters-agent-protocols (69 pass), cli-native-acp-lifecycle
(15), cli-acp-tolerance, cli-adapters (64).
2026-10-16 11:21:36 +08:00
Ryder Sun a97afa9b5b feat: deliver attachments and knowledge bases to group Agents; one prompt module per mode
Group chats with several Agents run on the V4 path, which never forwarded the
user's attached files to the Agents (the send was validated against what each
Agent can read, then the files were dropped), and its prompts omitted the
knowledge-base hints, the answer language and the deliverable folder contract.

- attachments are now staged for manual group replies and for every turn of
  an automatic discussion; frozen prompts only gain the staged-file section
- manual group replies no longer use the phased 'proposal' template (asking
  for capabilities, work items and a receipt). prompts/manual-reply-prompt
  tells each Agent that everyone answers on their own, whether it may change
  files, and to answer in the user's language
- natural discussion prompts move to prompts/natural-discussion-prompt with
  named sections (brief, writer, owner, routing, receipt), now including the
  group name and topic, knowledge bases, the answer language and, for the
  writer, the .meldwork-output contract
- shared language and delivery contracts live in prompts/prompt-contracts

Also made the hanging-ACP-initialize test independent of spawn counting: under
load the 300 ms timeout killed the first process before it recorded itself, so
the next spawn counted as the first one and hung (16 of 32 stressed runs).

Checked with: new tests for each prompt module and for attachment delivery
(both fail on the previous commit); the V4, context-pack and delivery test
files pass.
2026-10-16 10:49:44 +08:00
Ryder Sun 3ce566f152 refactor: split the invocation's session, outputs, budget, prompt and failure handling into modules
The 1500-line invokeLeased still mixed session bookkeeping, workspace output
capture, budget accounting, prompt assembly and failure classification with
the run lifecycle. Each is now a small module with unit tests:

- InvocationSession: one place that resumes, adopts, discards (rebuilds) and
  completes the native session; the three copies of the 'start a fresh
  session' dance, one of them dead code, are gone
- InvocationOutputs: workspace baseline, media import and outcome descriptors
- invocation-budget: usage recording; a payload without a recorded wire size
  no longer fails the turn
- invocation-prompt: the prompt one Agent receives (frozen, direct, group)
- invocation-failure: why an invocation ended (stopped, timed out,
  interrupted, cancelled, failed) and the hidden error metadata
- permission helpers for resumed decisions moved next to the permission policy

invokeLeased is about 1000 lines shorter and reads as the run lifecycle.
Behaviour is unchanged; focused invocation tests and the new module tests
pass.
2026-10-16 10:21:25 +08:00
Ryder Sun 68145eb319 refactor: extract receipt parsing, watchdog, stream marker filter and permission policy from the invocation
local-workspace-agent-invocation.cjs held four independent pieces of logic
inside one 1500-line method. They now live in small modules with their own
tests, and the invocation only wires them together:

- collaboration/collaboration-receipt.cjs: parse and normalise the receipt
  (unchanged behaviour; the existing receipt tests now import it directly)
- runs/invocation-watchdog.cjs: startup, inactivity and stall windows
- collaboration/answer-marker-filter.cjs: hides control markers while the
  answer streams
- agents/agent-permission-policy.cjs: automatic answers to permission
  requests, read-only turns only approve side-effect-free tools

Two latent defects fixed on the way:
- the watchdog cleared its timer on the first event without re-arming it, so
  an Agent that went silent right after its first event was never stopped
- the marker filter upper-cased the buffer to find markers, which shifts the
  cut position for characters whose upper-case form is longer (e.g. ss)

Checked with: node --test on the new module tests; npm --prefix desktop run
test:workspace (595 pass).
2026-10-16 10:09:38 +08:00
Ryder Sun c3c090f83b fix: write state compactly and durably, and record main-process faults
- The workspace state is rewritten synchronously on the main process for
  every save. It is now compact JSON written through the private atomic
  writer, which fsyncs before renaming (the old path could leave a
  truncated file after a crash). The Run Ledger snapshot is compact too.
- Unhandled rejections and uncaught exceptions in the main process are
  written to a size-capped, secret-redacted meldwork-diagnostics.log in
  userData instead of disappearing, and a renderer that crashes is
  reloaded instead of leaving a blank window.

Checks: run-ledger, local-workspace-state, local-workspace and
main-security tests (185 pass). Full desktop suite: 1540 pass; frontend
web and desktop builds pass (existing large-chunk warning only).
2026-10-15 19:38:33 +08:00
Ryder Sun 26d4be2404 fix: compare installed Agent CLI versions as releases
The installer decided "up to date" and "already installed" with a
substring test, so 11.2.3 counted as 1.2.3 and a newer CLI was offered a
downgrade. It now extracts both versions and compares them as releases
(installed is current when it is the recipe's version or newer), falling
back to the old string match only when a version cannot be parsed.

Checks: agent-installer and the installer facade API test (37 pass,
including a new version comparison test). Full desktop suite: 1540 pass.
2026-10-15 19:38:33 +08:00
Ryder Sun 00ba2d3b38 fix: import only files an Agent wrote, including paths with spaces
28127b3 imports files whose absolute paths appear in a reply, to catch
outputs written outside .meldwork-output. Those candidates skipped the
baseline and start-time checks, so a file the Agent merely mentioned
(the README it read, the user's staged attachment) was imported as an
output, and paths with spaces were cut at the first space.

Reported paths are now imported only when their content changed during
this run and they are outside the .meldwork-input staging folder.
Backticked, quoted and Markdown-linked paths keep their spaces.

Checks: agent-output-importer (11 pass, including a new reported-path
test). Full desktop suite: 1540 pass.
2026-10-15 19:38:33 +08:00
Ryder Sun 17cbd32387 fix: keep one writer per folder and show when an Agent waits for it
9198bf6 gave every direct chat its own scheduler key so that one writable
chat would not block another. That broke the one-writer-per-folder rule:
two Agents could edit the same files at once, and a direct chat and a
group could write the same folder concurrently. The underlying problem
was that a writer waiting for the folder lease showed nothing and looked
like a dead Agent (no queued event, no watchdog, no output).

- Scheduler requests report onQueued/onGranted when they actually wait.
- Direct chats use the real-folder key again; while an Agent waits for
  the folder, the run lists it in waitingForWorkspaceKinds and the run
  status card says it is waiting for another Agent writing in the same
  folder (zh/en). A stopped wait clears the mark.

Checks: run-scheduler (new queue notice test), write-scheduling (the
concurrent-direct-chat test now asserts the second writer waits, is shown
as waiting, then runs), run-coordinator public run keys. Full desktop
suite: 1537 of 1538 before adding the new public key to that list, which
now passes; npm --prefix frontend test (330).
2026-10-15 19:17:03 +08:00
Ryder Sun 8d8f9e65f6 fix: let discussion peers see workspace file paths instead of [path]
Peer transcripts in natural discussions replaced every token that starts
with "/" by [path]. Agents working on the same repository could not tell
each other which file they changed, and API routes such as /api/users or
commands such as /login were erased too.

Natural discussion transcripts now show files inside the shared
workspace as workspace-relative paths, keep non-filesystem tokens, and
still redact other absolute local paths (/Users, /tmp, ...) and secrets.
Phased-V4 delivery text keeps the previous redaction because its hashes
take part in resume checks.

Checks: collaboration-records (new peer path test), natural discussion
(new end-to-end relative path test), natural transcript and delivery
suites. Full desktop suite: 1537 pass.
2026-10-15 19:04:42 +08:00
Ryder Sun b2249f0859 fix: stop skipping every WorkBuddy permission check in write mode
28127b3 added --dangerously-skip-permissions next to acceptEdits when a
conversation allows workspace writes. That flag lets the CLI run any
command and write anywhere on the machine without confirmation, which
goes beyond Meldwork's opt-in workspace-write boundary and differs from
every other Agent. Write mode now relies on acceptEdits, which accepts
file edits in the working directory.

The local machine has no CodeBuddy CLI, so an edit-only tool allowlist
was not added; an unverified flag could break every WorkBuddy turn.

Checks: new cli-adapters test for WorkBuddy write/read-only arguments;
WorkBuddy adapter and protocol tests (7 pass). Full desktop suite: 1537.
2026-10-15 19:04:42 +08:00
Ryder Sun 6e0becfb70 fix: refuse unsafe ACP permission requests during read-only turns
Local invocations answer ACP permission prompts automatically. The
handler picked the "allow" option for every request, ignoring whether the
turn was read-only, so a read-only discussion participant (for example
Hermes running with command approval prompts) had edit, delete, move,
command execution and mode-switch requests approved.

Read-only turns now approve only side-effect-free tool kinds (read,
search, fetch, think) and refuse everything else, including requests
without a recognised kind. Writable turns and the explicit Human Gate
mode are unchanged.

Checks: new local-workspace-permission-policy tests (read-only refusal
matrix, writable approval). Full desktop suite: 1537 pass.
2026-10-15 19:04:42 +08:00
Ryder Sun 6c0f773d3f fix: ask discussion Agents to engage with each other and stop nudging them to stop
The natural discussion prompt told the owner "another reviewer is
optional", told every Agent it "may finish with nextKinds: []", and showed
a receipt example with empty nextKinds and deliverables. Proposals were
told to "engage with peers" although they run independently; sequential
discussions advertised routing that the code ignores; and the response
scope pointed at an "authoritative" label that natural prompts never
show. Agents therefore rarely requested each other and discussions
collapsed into one owner turn.

- Proposals: say peers answer independently and will be discussed next
  round. Discussion turns: respond to peers' specific points, dispute
  with reasons, build on their work; end every turn with the receipt.
- Owner: before completing, make sure the relevant peers reviewed the
  actual deliverables, otherwise request them with a concrete question.
- Peers: answer the points raised; blocked or needs-human goes to the
  owner, who decides.
- Routing: list every peer whose input is needed and ask them a concrete
  question; the receipt example names a real peer and shows the completed
  form with deliverables.
- Sequential discussions no longer advertise routing and instead say the
  next participant runs after this invocation ends.
- The response scope refers to "the current user task stated above".

Checks: local-workspace natural discussion, natural writer, delivery,
transcript, context-pack and harness suites (152 pass). Full desktop
suite: 1532 pass.
2026-10-15 18:50:46 +08:00
Ryder Sun d22c8eb1f0 fix: keep natural discussions going instead of ending on one slip
Automatic group discussions (the production auto path) ended as
"partial" for reasons that had nothing to do with the task:
- any receipt whose nextKinds named an absent or paused Agent stopped the
  whole discussion with "no valid task completion decision";
- a peer's blocked or needs-human status ended the run on the spot,
  although only the delivery owner judges the task;
- an owner who failed, was paused, or handed off to an absent Agent ended
  the run silently;
- one missing owner receipt stopped the run without asking again;
- strict receipt and taskDecision grammar failed the Agent's whole turn
  for "Done", an @handle, a single string instead of a list, a trailing
  comma or code fence, an extra key, or a long summary, and the failed
  Agent was then excluded from the discussion.

Now absent recipients are dropped with a notice and valid requests still
run; a peer blocker is forwarded to the owner, who decides; the next
active selected Agent takes over ownership with a notice; an ignored
handoff keeps the current owner; an owner without a readable decision is
asked once more and the run stops only after two undecided owner turns;
completing without stopped participants explains why the run is partial.
Discussion receipts are normalised (status synonyms, @handles, case,
lists, smart quotes, trailing commas, closing fences, extra keys, missing
summary) before the strict parser runs, and an unreadable decision is
treated as no decision instead of failing the turn. Phased-V4 receipts
keep their strict contract. New zh/en notices explain each case.

Checks: natural discussion, natural writer, receipt, task-decision and
human-gate recovery suites (99 pass on this tree, including a new fenced
loose-receipt test and updated tests for the new routing, blocker and
handoff behaviour); npm --prefix frontend test (330). Full desktop suite
with the following prompt commit: 1532 pass.
2026-10-15 18:50:46 +08:00
Ryder Sun f994225990 test: give the hanging ACP initialize test a realistic second-spawn timeout
The test used a 100 ms initialize timeout for both turns. Under a loaded
full-suite run the healthy second fake-Agent process also needed more than
100 ms to start, so it fell back too. Only the first process hangs; the
second turn now uses the default timeout.

Checks: the lifecycle test passes three runs in a row.
2026-10-15 18:28:52 +08:00
Ryder Sun 06e1e8772c fix: render group replies in the order Agents were dispatched
No message carried a turn order. The timeline rebuilt one from round,
phase, the Agent's current position in the group and the save time, and
those disagreed:
- live bubbles had numeric startedAt, which Date.parse turns into NaN, so
  every running Agent was pinned under the user message and the list
  reshuffled each time a reply was saved;
- manual concurrent mode wrote failure notices before the barrier
  committed successful replies, so failures always rendered first;
- round-less notices sorted after every round, the 'discussion' phase
  had no rank, and editing the group reordered history;
- natural proposals were saved in completion order and peers read the
  transcript in that order, unlike what the user saw.

Now every group topic message gets a per-topic turnSeq. Batch
dispatchers (manual concurrent replies, natural proposal and discussion
batches) reserve numbers in slot order before dispatching; other turns
reserve at invocation start; failure and stop notices take their turn's
number; other topic messages take the next number when written. The
harness exposes the number on live Agent runs, so a live bubble and the
reply that replaces it share one position. Numbers are derived from saved
messages after a restart (no ledger or journal field changes). The
renderer sorts group topics by turnSeq with one shared rule for the
timeline and the trace panel; older history keeps its saved chronological
order. A failed attempt whose retry was already saved no longer lingers
as a ghost bubble, and peers read turns in dispatch order.

Checks: new local-workspace-turn-order tests (slot order with a failing
middle Agent and reversed completion; numbering continues after restart;
none on direct chats), natural discussion, manual durability and write
scheduling tests (63 pass on this tree); npm --prefix frontend test
(330 pass, including a new conversationOrdering spec and a
production-shaped live round test). Full desktop suite on the combined
tree: 1529 of 1530, the remaining failure being the ACP timing test fixed
in the next commit.
2026-10-15 18:28:52 +08:00
Ryder Sun 6522d357e0 fix: keep long-lived ACP Agents usable across stops, restarts and hangs
Persistent ACP runtimes (Hermes, Kimi, MiMo, OpenCode) were fragile:
- any turn error, including a user stop or watchdog timeout, destroyed
  the process and its native session; Hermes rejects cross-process
  resume, so the conversation then fell back to the silent legacy CLI;
- the process identity hashed the whole child environment, so a fresh
  login-shell PATH (fnm uses a per-shell directory) restarted the process
  and invalidated the stored session;
- initialize had no timeout and ignored stop while holding the
  conversation lock, leaving that Agent stuck until the app restarted;
- stderr kept the first megabyte of the process lifetime, so new
  failures were classified from stale lines.

Now a stopped or timed-out turn waits up to 3 s for the Agent to answer
the cancelled prompt and keeps the process and session when it does;
volatile shell values (PATH, TERM, TMPDIR, fnm variables, ...) no longer
change the process identity; initialize times out after 60 s (settable
per call) and honours stop without falling back; stderr keeps only the
current turn's last 64 KB. Disposable runtimes still close after every
turn.

Checks: cli-native-acp-lifecycle with three new fake-ACP tests (hanging
initialize releases the lock, a cancelled turn reuses one process and
session, a changed PATH keeps the process), plus the existing lifecycle,
adapter and protocol suites (185 pass). Full desktop suite: 1528 pass.
2026-10-15 18:04:17 +08:00
Ryder Sun 28fb01eff5 fix: stop the watchdog from killing Agents that are still working
Group runs gave an Agent three minutes to produce a mapped runtime event,
and only mapped events lifted that deadline. ACP agent_thought_chunk was
mapped to nothing, so Hermes, Kimi, MiMo, OpenCode and OpenClaw were
killed while thinking, and auto mode never retries timeouts. The 3/5
minute inactivity windows also cut off real builds and slow providers.

- Any sign of life (stdout/stderr bytes, ACP traffic) now lifts the
  startup deadline. In automatic discussions a separate no-progress cap
  (three inactivity windows) still stops CLIs that only emit heartbeats,
  so a stalled participant cannot block the round; manual and direct
  turns keep running while the process is active.
- Defaults are 10 minutes of inactivity and 20 minutes while a tool runs.
- ACP thinking is reported as a text-free reasoning status event
  (running, then completed when the answer starts); the private thought
  text never leaves the main process.

Checks: local-workspace-harness (74, including a new slow-starting Agent
test that failed with the old hard deadline, and the existing
heartbeat-only stop test), cli-adapters and cli-adapters-agent-protocols
(event order now includes the reasoning status). Full desktop suite:
1528 pass.
2026-10-15 18:04:17 +08:00
Ryder Sun 5fc70afde9 fix: explain run failures in Chinese and English instead of raw codes
Several codes that end an Agent turn or a whole run had no translation,
so the timeline showed a generic error or the raw code (for example
LOCAL_RUN_PERSIST_FAILED), and users could not tell why a discussion
stopped. The round-limit notice also claimed "no consensus" although
natural discussions have no consensus step.

- Map access-denied, session-invalid, output-limit, send-failed,
  refused, permission race, version/capability/protocol mismatch,
  task-decision and receipt errors, context overflow, persistence
  failures and the circuit breaker to zh/en messages.
- The round-limit notice now says the latest replies are the current
  result and how to continue (backend fallback text updated too).

Checks: npm --prefix frontend test (325 pass); updated assertions that
expected the raw code and the old round-limit wording.
2026-10-15 17:40:04 +08:00
Ryder Sun 35ed0d4e4a fix: send Hermes image attachments over ACP instead of dropping them
eb2234b stopped switching Hermes to its legacy transport for image
attachments to preserve the ACP session, but the ACP runner only sends a
text block, so images reached Hermes as nothing at all (the adapter test
for this failed on bb55fad).

Hermes 0.21.5 advertises promptCapabilities.image during initialize. The
runner now keeps the initialize capabilities, and when an ACP spec
carries imageAttachments it sends them as ACP image content blocks
(png/jpeg/gif/webp, up to 10 MB each) on the same session. An Agent that
does not advertise image prompts fails before prompting, so the existing
setup fallback uses the legacy transport with its --image argument.

Checks: cli-adapters (Hermes invocation spec) and a new
cli-native-acp-lifecycle test with fake Hermes ACP agents covering the
image-capable path and the legacy fallback. The capability response was
verified against the locally installed Hermes 0.21.5 with an
initialize-only handshake (no session or model call).
2026-10-15 17:39:43 +08:00
Ryder Sun 624a099bdd fix: give direct chats the current request, delivery contract, and full task
Commit 9198bf6 made direct chats send only the latest message text and
rely on the native session. After 1c9aabb a rebuilt session received the
continuation text instead, and that text deliberately excludes the
latest user message, so the Agent never saw the request it was answering.
Direct prompts also lost the deliverable contract (.meldwork-output/),
the read-only media warning, staged attachment paths, and regeneration
and recovery instructions, which is why generated files were not
captured and three media tests failed on bb55fad.

- New directPromptFor() leads with the user's own words. A new or rebuilt
  native session gets the earlier conversation plus "Current request:";
  a resumed session gets the request alone. The full delivery and
  language contract is sent once per native session, a one-line reminder
  afterwards. Staged inputs, recovery and regeneration instructions are
  appended again.
- The shared deliveryContract() is used by group and direct prompts.
- Isolated (task-graph) invocations again neither resume nor persist the
  conversation's native session (3ce8ee4 made them resume it, breaking
  the "approved prompt only" contract).
- The current task is no longer cut to 6,000 characters, and longer
  messages no longer fail every turn with
  LOCAL_RUN_REQUIRED_CONTEXT_OVERFLOW: submitted messages are already
  capped at 20,000 characters, so the full request is delivered.

Checks: local-workspace-harness (73) and local-workspace-context-packs
(15) pass on this tree, plus the direct, media and custom-label tests in
local-workspace and native-streaming (18), five of which failed on
bb55fad.
2026-10-15 17:39:18 +08:00
Ryder Sun 2da40db63d fix: tier authentication failures and retry transient provider errors
The merged branches disagreed on authentication failures: feature/1.0.5
marked every runtime auth failure 'unknown' so readiness never flipped,
while main's 22f811c classified any auth-looking diagnostic as
LOCAL_AGENT_AUTH_REQUIRED. Together with broad regexes (a bare
"forbidden" anywhere, a credential regex matching "login" or "403"),
proxy refusals stopped Agents while real sign-outs left them "ready".

- New pure module runs/failure-signals.cjs classifies CLI text:
  explicit sign-in or API-key statements are definitive; line-leading
  403/forbidden/unauthorized and permission_error are ambiguous; tool
  output that merely mentions 403 is neither. It also recognises rate
  limits (with Retry-After hints), network failures, provider 5xx and
  context overflow.
- Definitive failures stay LOCAL_AGENT_AUTH_REQUIRED and now mark the
  credential 'missing' (the catalog clears it after the retry window or
  when native credentials verify). Ambiguous refusals become
  LOCAL_AGENT_ACCESS_DENIED, are retried once, and never touch readiness.
- Process failures whose diagnostics show throttling, network loss or a
  provider outage are retried in that category. Rate limits honour
  Retry-After up to 60 s and wait at least 2 s without a hint.
- A resumed session that no longer fits the model context is classified
  as LOCAL_AGENT_SESSION_INVALID so the existing fresh-session retry
  rebuilds it; Rust panics and JSON parse errors no longer discard a
  healthy session.
- Automatic retries no longer re-run the whole Agent catalog refresh
  before each attempt, which flipped busy Agents to unavailable.

Checks: failure-signals (new), failure-policy, cli-process-support,
cli-adapters-agent-protocols, agent-runtime-contract and the auth/retry
tests in local-workspace and local-workspace-auto pass on this tree,
including three readiness tests that failed on bb55fad.
2026-10-15 17:37:57 +08:00
Ryder Sun 1033d086d3 fix: keep group members after Agent failures and recover emptied groups
An authentication or compatibility failure in an automatic discussion
called removeAgent, which rewrote and persisted group.agentKinds. One
proxy 403 or expired OAuth refresh made the Agent disappear from every
later turn of that group, and when the last member was removed the group
could no longer load: normalizeLoadedGroup rejects an empty member list,
so the next start silently dropped the group together with all of its
messages.

- The auto runner now pauses a failed Agent only for the current run.
  Group membership is never edited; the run-scoped exclusion already
  persisted in orchestration activeKinds keeps resume behaviour intact.
- The notice says the Agent is paused for this discussion, with the
  failure reason, and stays in the group (system.agentPausedAfterFailure,
  zh/en). The removal notice text is kept so history still renders.
- removeAgent refuses to remove a group's last member.
- Loading rebuilds an emptied group's members from the Agents that
  replied in it instead of dropping the group and its history.

Checks: node --test local-workspace-state.test.cjs (3 pass, including a
new recovery test) and the membership tests in
local-workspace-auto.test.cjs. Two tests there that also expect a
'missing' credential state stay failing until the next commit (they
already failed on bb55fad).
2026-10-15 17:36:07 +08:00
Ryder Sun 4f31b77503 chore: remove scratch files committed at the repository root
Commit 6aef211 added temporary icon exports, two large PNG screenshots
and a confirmation.json backup to the repository root. AGENTS.md forbids
placing screenshots and temporary files there and nothing references
them.

Checks: rg found no references to the removed paths.
2026-10-15 16:33:10 +08:00
Ryder Sun 6b6be1403e Merge main into feature/1.0.5
Bring main's 22f811c (auth classification from errors[], OpenClaw runtime
scope per CLI version, full participant round and cross-review gate for
natural discussions) and eac015d (removal of the managed shared Provider)
into the V1.0.5 branch before the stability work.

Conflict resolution:
- local-workspace-agent-catalog.cjs: keep feature's guard that an
  'unknown' runtime credential does not downgrade an authenticated Agent;
  drop the provider-credential-unavailable clause removed on main. The
  tiered auth policy is reconciled in a follow-up commit.
- README.md / README.zh-CN.md: keep feature's V1.0.5 summary and apply
  main's wording that Agents use their own CLI's model provider.
- docs/*: keep feature's untracking of local documentation drafts.
- cli-adapters-discovery.test.cjs: both sides were equivalent.

Checks: npm --prefix frontend test (325 pass). npm --prefix desktop test:
1503/1515 pass. The 12 failures are pre-existing on feature/1.0.5 at
bb55fad (verified by running them on a detached worktree of bb55fad) and
are addressed by the follow-up fixes.
2026-10-15 16:32:53 +08:00
Ryder Sun eac015dbfa refactor: remove the managed shared Provider capability
Each Agent CLI now runs only with its own native credentials. The central
path of configuring one provider inside Meldwork for every Agent added
instability without being realistic to maintain, and it depended on
machine-local configuration details.

- Delete the provider store/options modules, the local-agent-provider IPC
  handlers and preload API, per-CLI env injection (CODEBUDDY_*/ANTHROPIC_*/
  KIMI_* ...), the managed OpenClaw provider runtime, and shared-provider
  catalog gating; startup removes the legacy encrypted
  meldwork-provider.json.
- OpenClaw always resolves its native runtime now; native credential
  passthrough, the isolated gateway/guard runtime, and the
  runtime-auth-failure state machine stay unchanged.
- Remove the Provider settings UI, onboarding slide, providerMode display,
  ~100 i18n keys per language, provider CSS, and update the architecture,
  security, and discoverability docs to the native-credentials boundary.

Checks: npm --prefix desktop test (1512 pass), npm --prefix frontend test
(325 pass), frontend build and build:desktop, desktop pack.
2026-10-14 16:59:21 +08:00
Ryder Sun 22f811cde2 fix: harden local agent runs and broaden natural discussion rounds
- Extract WorkBuddy/Claude/Qwen error-result lists (errors[]) into failure
  diagnostics and classify authentication failures as
  LOCAL_AGENT_AUTH_REQUIRED instead of a bare LOCAL_AGENT_PROCESS_FAILED,
  so the catalog marks the Agent as needing a fresh login and retries stop.
- Scope the isolated OpenClaw runtime directory by session, workspace, and
  CLI version, so an upgraded OpenClaw gets a fresh managed scope instead
  of failing on legacy workspace state written by an older CLI.
- Natural agent-led discussions now open with a full participant round and
  the delivery owner cannot declare completion before peers had a
  discussion round; sequential runs get the same cross-review gate
  (system.autoCrossReview).

Checks: npm --prefix desktop test (1574 pass), npm --prefix frontend test
(343 pass), frontend build and build:desktop, desktop pack.
2026-10-14 16:52:25 +08:00
216 changed files with 13556 additions and 13266 deletions

No files matched your search

-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 1024 1024">
<path d="M854.6 288.7c6 6 9.4 14.1 9.4 22.6V928c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32h424.7c8.5 0 16.7 3.4 22.7 9.4l215.2 215.3zM790.2 326L602 137.8V326h188.2zM575.34 477.84l-61.22 102.3L452.3 477.8a12 12 0 0 0-10.27-5.79h-38.44a12 12 0 0 0-6.4 1.85 12 12 0 0 0-3.75 16.56l82.34 130.42-83.45 132.78a12 12 0 0 0-1.84 6.39 12 12 0 0 0 12 12h34.46a12 12 0 0 0 10.21-5.7l62.7-101.47 62.3 101.45a12 12 0 0 0 10.23 5.72h37.48a12 12 0 0 0 6.48-1.9 12 12 0 0 0 3.62-16.58l-83.83-130.55 85.3-132.47a12 12 0 0 0 1.9-6.5 12 12 0 0 0-12-12h-35.7a12 12 0 0 0-10.29 5.84z"/>
</svg>

Before

Width:  |  Height:  |  Size: 719 B

-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 1024 1024">
<path d="M854.6 288.7L639.4 73.4c-6-6-14.2-9.4-22.7-9.4H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V311.3c0-8.5-3.4-16.6-9.4-22.6zM400 402c22.1 0 40 17.9 40 40s-17.9 40-40 40-40-17.9-40-40 17.9-40 40-40zm296 294H328c-6.7 0-10.4-7.7-6.3-12.9l99.8-127.2a8 8 0 0 1 12.6 0l41.1 52.4 77.8-99.2a8 8 0 0 1 12.6 0l136.5 174c4.3 5.2.5 12.9-6.1 12.9zm-94-370V137.8L790.2 326H602z"/>
</svg>

Before

Width:  |  Height:  |  Size: 530 B

-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 1024 1024">
<path d="M854.6 288.7c6 6 9.4 14.1 9.4 22.6V928c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32h424.7c8.5 0 16.7 3.4 22.7 9.4l215.2 215.3zM790.2 326L602 137.8V326h188.2zM426.13 600.93l59.11 132.97a16 16 0 0 0 14.62 9.5h24.06a16 16 0 0 0 14.63-9.51l59.1-133.35V758a16 16 0 0 0 16.01 16H641a16 16 0 0 0 16-16V486a16 16 0 0 0-16-16h-34.75a16 16 0 0 0-14.67 9.62L512.1 662.2l-79.48-182.59a16 16 0 0 0-14.67-9.61H383a16 16 0 0 0-16 16v272a16 16 0 0 0 16 16h27.13a16 16 0 0 0 16-16V600.93z"/>
</svg>

Before

Width:  |  Height:  |  Size: 635 B

-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 1024 1024">
<path d="M854.6 288.7c6 6 9.4 14.1 9.4 22.6V928c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32h424.7c8.5 0 16.7 3.4 22.7 9.4l215.2 215.3zM790.2 326L602 137.8V326h188.2zM633.22 637.26c-15.18-.5-31.32.67-49.65 2.96-24.3-14.99-40.66-35.58-52.28-65.83l1.07-4.38 1.24-5.18c4.3-18.13 6.61-31.36 7.3-44.7.52-10.07-.04-19.36-1.83-27.97-3.3-18.59-16.45-29.46-33.02-30.13-15.45-.63-29.65 8-33.28 21.37-5.91 21.62-2.45 50.07 10.08 98.59-15.96 38.05-37.05 82.66-51.2 107.54-18.89 9.74-33.6 18.6-45.96 28.42-16.3 12.97-26.48 26.3-29.28 40.3-1.36 6.49.69 14.97 5.36 21.92 5.3 7.88 13.28 13 22.85 13.74 24.15 1.87 53.83-23.03 86.6-79.26 3.29-1.1 6.77-2.26 11.02-3.7l11.9-4.02c7.53-2.54 12.99-4.36 18.39-6.11 23.4-7.62 41.1-12.43 57.2-15.17 27.98 14.98 60.32 24.8 82.1 24.8 17.98 0 30.13-9.32 34.52-23.99 3.85-12.88.8-27.82-7.48-36.08-8.56-8.41-24.3-12.43-45.65-13.12zM385.23 765.68v-.36l.13-.34a54.86 54.86 0 0 1 5.6-10.76c4.28-6.58 10.17-13.5 17.47-20.87 3.92-3.95 8-7.8 12.79-12.12 1.07-.96 7.91-7.05 9.19-8.25l11.17-10.4-8.12 12.93c-12.32 19.64-23.46 33.78-33 43-3.51 3.4-6.6 5.9-9.1 7.51a16.43 16.43 0 0 1-2.61 1.42c-.41.17-.77.27-1.13.3a2.2 2.2 0 0 1-1.12-.15 2.07 2.07 0 0 1-1.27-1.91zM511.17 547.4l-2.26 4-1.4-4.38c-3.1-9.83-5.38-24.64-6.01-38-.72-15.2.49-24.32 5.29-24.32 6.74 0 9.83 10.8 10.07 27.05.22 14.28-2.03 29.14-5.7 35.65zm-5.81 58.46l1.53-4.05 2.09 3.8c11.69 21.24 26.86 38.96 43.54 51.31l3.6 2.66-4.39.9c-16.33 3.38-31.54 8.46-52.34 16.85 2.17-.88-21.62 8.86-27.64 11.17l-5.25 2.01 2.8-4.88c12.35-21.5 23.76-47.32 36.05-79.77zm157.62 76.26c-7.86 3.1-24.78.33-54.57-12.39l-7.56-3.22 8.2-.6c23.3-1.73 39.8-.45 49.42 3.07 4.1 1.5 6.83 3.39 8.04 5.55a4.64 4.64 0 0 1-1.36 6.31 6.7 6.7 0 0 1-2.17 1.28z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 1024 1024">
<path d="M854.6 288.7c6 6 9.4 14.1 9.4 22.6V928c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32h424.7c8.5 0 16.7 3.4 22.7 9.4l215.2 215.3zM790.2 326L602 137.8V326h188.2zM468.53 760v-91.54h59.27c60.57 0 100.2-39.65 100.2-98.12 0-58.22-39.58-98.34-99.98-98.34H424a12 12 0 0 0-12 12v276a12 12 0 0 0 12 12h32.53a12 12 0 0 0 12-12zm0-139.33h34.9c47.82 0 67.19-12.93 67.19-50.33 0-32.05-18.12-50.12-49.87-50.12h-52.22v100.45z"/>
</svg>

Before

Width:  |  Height:  |  Size: 571 B

-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 1024 1024">
<path d="M854.6 288.7c6 6 9.4 14.1 9.4 22.6V928c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32h424.7c8.5 0 16.7 3.4 22.7 9.4l215.2 215.3zM790.2 326L602 137.8V326h188.2zM320 482a8 8 0 0 0-8 8v48a8 8 0 0 0 8 8h384a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8H320zm0 136a8 8 0 0 0-8 8v48a8 8 0 0 0 8 8h184a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8H320z"/>
</svg>

Before

Width:  |  Height:  |  Size: 479 B

-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 1024 1024">
<path d="M854.6 288.7c6 6 9.4 14.1 9.4 22.6V928c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32h424.7c8.5 0 16.7 3.4 22.7 9.4l215.2 215.3zM790.2 326L602 137.8V326h188.2zM512 566.1l52.81 197a12 12 0 0 0 11.6 8.9h31.77a12 12 0 0 0 11.6-8.88l74.37-276a12 12 0 0 0 .4-3.12 12 12 0 0 0-12-12h-35.57a12 12 0 0 0-11.7 9.31l-45.78 199.1-49.76-199.32A12 12 0 0 0 528.1 472h-32.2a12 12 0 0 0-11.64 9.1L434.6 680.01 388.5 481.3a12 12 0 0 0-11.68-9.29h-35.39a12 12 0 0 0-3.11.41 12 12 0 0 0-8.47 14.7l74.17 276A12 12 0 0 0 415.6 772h31.99a12 12 0 0 0 11.59-8.9l52.81-197z"/>
</svg>

Before

Width:  |  Height:  |  Size: 711 B

-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 1024 1024">
<path d="M854.6 288.7c6 6 9.4 14.1 9.4 22.6V928c0 17.7-14.3 32-32 32H192c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32h424.7c8.5 0 16.7 3.4 22.7 9.4l215.2 215.3zM790.2 326L602 137.8V326h188.2zM296 136v64h64v-64h-64zm64 64v64h64v-64h-64zm-64 64v64h64v-64h-64zm64 64v64h64v-64h-64zm-64 64v64h64v-64h-64zm64 64v64h64v-64h-64zm-64 64v64h64v-64h-64zm0 64v160h128V584H296zm48 48h32v64h-32v-64z"/>
</svg>

Before

Width:  |  Height:  |  Size: 516 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="currentColor" fill-rule="evenodd"><g><path d="M353 16H192.545v425.615c0 105.313-36.166 134.718-99.104 134.718-29.494 0-56.055-5.05-76.718-12.144L0 688.784C29.484 698.924 74.73 705 110.126 705 254.631 705 353 637.16 353 442.658zM702.49 0C547.26 0 449 88.126 449 204.609c0 100.313 75.67 163.12 185.696 203.629 79.577 28.358 111.03 53.695 111.03 95.218 0 45.579-36.358 74.96-105.13 74.96-63.868 0-121.849-21.311-161.146-42.573v-.042L449 662.427C486.361 683.735 556.12 705 631.741 705 813.52 705 898 607.753 898 493.293c0-97.243-54.036-160.035-170.937-204.627-86.47-34.432-122.813-53.669-122.813-97.227 0-34.45 31.446-65.834 96.3-65.834 63.834 0 107.728 21.445 133.307 34.632L872.193 32.05C832.103 14.461 778.109 0 702.49 0" transform="translate(63 160)"/></g></svg>

Before

Width:  |  Height:  |  Size: 833 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="currentColor" fill-rule="evenodd"><path d="M212.68 692.99s-34.325 19.95 24.343 26.6c71.1 8.05 107.351 7 185.632-7.874 0 0 20.665 12.949 49.385 24.149-175.475 75.074-397.184-4.375-259.36-42.875m-21.366-98.173s-38.352 28.35 20.315 34.475c75.83 7.875 135.897 8.4 239.571-11.55 0 0 14.36 14.525 36.952 22.4-212.427 62.123-448.846 5.075-296.838-45.325m180.73-166.422c43.256 49.699-11.384 94.498-11.384 94.498s109.804-56.7 59.368-127.573c-47.11-66.15-83.185-99.049 112.255-212.273.175 0-306.819 76.65-160.24 245.348M604.26 765.439s25.393 20.825-27.846 37.1c-101.397 30.625-421.7 39.9-510.664 1.225-32.048-13.825 28.02-33.25 46.934-37.275 19.613-4.2 30.997-3.5 30.997-3.5-35.551-25.025-229.94 49.175-98.771 70.35 357.605 58.1 652.165-26.075 559.35-67.9M229.142 493.144S66.1 531.818 171.35 545.818c44.482 5.95 133.095 4.55 215.58-2.275 67.423-5.6 135.196-17.85 135.196-17.85s-23.818 10.15-40.98 21.875C315.653 591.143-3.95 570.843 87.99 526.393c77.93-37.45 141.151-33.25 141.151-33.25M521.6 656.416c168.296-87.324 90.365-171.322 36.077-159.948-13.31 2.8-19.264 5.25-19.264 5.25s4.903-7.7 14.36-11.025c107.351-37.8 190.01 111.299-34.675 170.273 0-.175 2.627-2.45 3.502-4.55M420.028 0s93.166 93.1-88.438 236.246c-145.53 114.8-33.274 180.424 0 255.148-84.936-76.65-147.28-144.024-105.425-206.848C287.634 192.672 457.68 147.873 420.028 0m-174.25 893.188c161.466 10.325 409.443-5.775 415.222-82.075 0 0-11.208 28.875-133.445 51.975-137.824 25.9-307.87 22.925-408.567 6.3 0-.175 20.665 16.975 126.79 23.8" transform="translate(182 64)"/></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="currentColor" fill-rule="evenodd"><g><path d="M443 678.5c0 15.74 12.76 28.5 28.5 28.5s28.5-12.76 28.5-28.5-12.76-28.5-28.5-28.5-28.5 12.76-28.5 28.5M300 121.5c0 15.74 12.76 28.5 28.5 28.5s28.5-12.76 28.5-28.5S344.24 93 328.5 93 300 105.76 300 121.5" transform="translate(112 112)"/><path d="M709.524 185.714h-95.238V90.476C614.286 40.571 573.714 0 523.81 0H276.19c-49.904 0-90.476 40.571-90.476 90.476v95.238H90.476C40.571 185.714 0 226.286 0 276.19v247.62c0 49.904 40.571 90.476 90.476 90.476h95.238v95.238c0 49.905 40.572 90.476 90.476 90.476h247.62c49.904 0 90.476-40.571 90.476-90.476v-95.238h95.238c49.905 0 90.476-40.572 90.476-90.476V276.19c0-49.904-40.571-90.476-90.476-90.476M90.476 557.143c-18.38 0-33.333-14.953-33.333-33.333V276.19c0-18.38 14.952-33.333 33.333-33.333h278.572c15.81 0 28.571-12.762 28.571-28.571 0-15.81-12.762-28.572-28.571-28.572h-126.19V90.476c0-18.38 14.952-33.333 33.332-33.333h247.62c18.38 0 33.333 14.952 33.333 33.333v256.476c0 13.524-10.953 24.477-24.476 24.477H267.333c-45.047 0-81.619 36.666-81.619 81.619v104.095zm652.381-33.333c0 18.38-14.952 33.333-33.333 33.333H430.952c-15.81 0-28.571 12.762-28.571 28.571 0 15.81 12.762 28.572 28.571 28.572h126.19v95.238c0 18.38-14.952 33.333-33.332 33.333H276.19c-18.38 0-33.333-14.952-33.333-33.333V453.048c0-13.524 10.953-24.477 24.476-24.477h265.334c45.047 0 81.619-36.666 81.619-81.619V242.857h95.238c18.38 0 33.333 14.953 33.333 33.333z" transform="translate(112 112)"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+1 -1
View File
@@ -11,7 +11,7 @@
- Meldwork is a local-first desktop application. Do not introduce a required server, container, or cluster dependency unless the task explicitly calls for it.
- Keep Agent execution in the Electron main process. Renderer access must go through narrow, validated preload APIs.
- Never expose executable paths, tokens, provider credentials, or unrestricted shell execution to renderer code.
- Never expose executable paths, tokens, credentials, or unrestricted shell execution to renderer code.
- Store secrets only through operating-system-backed secure storage. Do not add plaintext fallbacks.
- Keep workspace write access opt-in. Conversations and groups stay local; do not add remote participants, cloud channels, or remote conversation storage.
+42
View File
@@ -1,5 +1,47 @@
# Changelog
## 0.1.6 - 2026-10-17 (Meldwork-V1.0.6 prerelease)
### Agent Reliability
- Tier authentication failures instead of treating any authentication-looking diagnostic as a sign-in problem, and retry transient provider errors.
- Keep long-lived ACP Agents (Hermes, Kimi, MiMo, OpenCode) usable across stops, restarts, and hangs, including when a group alternates between read-only and write turns.
- Stop the watchdog from killing Agents that are still thinking or building, and keep an ACP turn alive through traffic this client does not recognise.
- Keep an Agent available through an inconclusive readiness probe and preserve its shell environment.
- Let MiMo and OpenCode turns that call tools finish instead of returning a partial answer, and report a CLI's own error instead of EPIPE.
- Launch WorkBuddy's app-bundled CLI again (it now ships only the lite bundle) and stop skipping every WorkBuddy permission check in write mode.
- Refuse unsafe ACP permission requests during read-only turns.
- Give direct chats the current request and the delivery contract again after a native session is rebuilt.
- Compare installed Agent CLI versions as releases, so a newer installed CLI is not offered a downgrade.
- Write workspace state compactly and durably, and record unhandled main-process faults in the diagnostics log.
- Internal: split the auto-runner and the Agent invocation into modules grouped by purpose.
### Group Conversations
- Show group replies in the order Agents were dispatched.
- Keep natural discussions going instead of ending on one slip, ask Agents to engage with each other, and let peers see workspace file paths.
- Keep group members after an Agent failure, so one proxy 403 or an expired sign-in no longer removes an Agent from later turns, and recover groups that were emptied.
- A member that is not ready no longer blocks a message to the whole group; skipped members are named.
- Deliver attachments and knowledge bases to group Agents, and send Hermes image attachments over ACP.
- Conversations no longer wait for each other, even in the same folder; writers inside one conversation still take turns.
- Import only files an Agent wrote, including paths with spaces.
- Explain run failures in Chinese and English instead of showing raw codes.
### Interface
- Start without waiting for Agent detection: the sidebar, home page, and composer show that detection is running.
- Mark conversations that wait for your decision in the sidebar and list them on the home page; mark group runs that finished while you were in another conversation.
- Add a General settings tab with language and a System, Light, or Dark appearance; System follows the operating system until you choose.
- Explain on each Agent card why an installed Agent is unavailable: its installed version, the verified range, and the failed check.
- Use a shared type scale and semantic status colors, keep secondary text within contrast guidelines, and keep long notices on screen long enough to read.
- Keep unsent text per conversation, add Skip and arrow-key navigation to first-run onboarding, ask before deleting a cloud server connection, and show Gemini CLI's own logo.
### Verification And Distribution
- Frontend 357/357, desktop 1603/1603, and deterministic evaluation 6 cases / 18 results passed on a clean checkout.
- DMG and ZIP are ad-hoc signed, without Apple Developer ID signing or notarization. No installed user application is replaced automatically.
- Live coverage is limited to observed CLI versions and configurations; this release did not repeat the live Agent acceptance run.
## 0.1.5 - 2026-09-09 (Meldwork-V1.0.5 prerelease)
### Local Agent Reliability
+1 -1
View File
@@ -9,7 +9,7 @@ Unless a separate written agreement applies, a contribution accepted into this r
- Keep changes small and tied to a clear product or engineering need.
- Do not submit secrets, tokens, private logs, private prompts, or user data.
- Do not add remote services, telemetry, credential collection, or server dependencies without explicit project approval.
- Preserve the local-first boundary: renderer code must not gain unrestricted filesystem, shell, Provider credential, or executable-path access.
- Preserve the local-first boundary: renderer code must not gain unrestricted filesystem, shell, credential, or executable-path access.
- Include focused tests for behavior, security boundary, storage, or IPC changes.
- Confirm that all submitted code and assets are yours to contribute and do not introduce incompatible license obligations.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

+1 -1
View File
@@ -89,7 +89,7 @@ Approved Agent Connectors and custom executable Agents use the desktop connector
Meldwork runs as a local Electron application. Conversations, group configuration, run records, and app-owned attachments stay in the local user data directory. The renderer receives only validated snapshots through a narrow preload API; executable paths, credentials, native session references, Skill paths, and unrestricted shell access remain in the main process.
Selected Agents may send prompts, attachments, or Skills to their configured model Provider. Knowledge access is explicit and bounded: the current implementation supports local Obsidian retrieval and CLI-owned Feishu or DingTalk access modes. Local-first describes where Meldwork stores and coordinates work; it does not promise that a configured Agent Provider is offline.
Selected Agents may send prompts, attachments, or Skills to the model provider configured in their own CLI. Knowledge access is explicit and bounded: the current implementation supports local Obsidian retrieval and CLI-owned Feishu or DingTalk access modes. Local-first describes where Meldwork stores and coordinates work; it does not promise that an Agent's model provider is offline.
## Download V1.0.5
+1 -1
View File
@@ -89,7 +89,7 @@ Run Ledger 会保留阶段、参与者、尝试、回执、产物、恢复状态
Meldwork 作为本地 Electron 应用运行。对话、群组配置、运行记录和应用管理的附件保存在本地用户数据目录。渲染进程只通过受约束的 preload API 获取校验后的状态;可执行路径、凭据、原生会话引用、Skill 路径和任意 Shell 权限都留在主进程中。
选定的 Agent 仍可能把 Prompt、附件或 Skill 发送给其配置的模型 Provider。知识访问必须明确选择并受边界约束:当前实现支持本地 Obsidian 检索,以及由本地 CLI 管理的飞书或钉钉访问模式。Local-first 描述的是 Meldwork 的数据和协作位置,不代表已配置的 Agent Provider 一定离线。
选定的 Agent 仍可能把 Prompt、附件或 Skill 发送到其自身 CLI 凭据对应的模型服务商。知识访问必须明确选择并受边界约束:当前实现支持本地 Obsidian 检索,以及由本地 CLI 管理的飞书或钉钉访问模式。Local-first 描述的是 Meldwork 的数据和协作位置,不代表 Agent 使用的模型服务商一定离线。
## 下载 V1.0.5
+3 -3
View File
@@ -1,6 +1,6 @@
# Security Policy
Meldwork is a local-first desktop application that invokes local Agent CLIs, stores local conversations, imports validated image, media, document, code, and archive attachments, and manages Provider credentials through the Electron main process.
Meldwork is a local-first desktop application that invokes local Agent CLIs, stores local conversations, imports validated image, media, document, code, and archive attachments, and runs each Agent with its own native CLI credentials through the Electron main process.
## Reporting
@@ -11,10 +11,10 @@ Do not open a public issue with exploit details, secrets, local file paths, or p
## In Scope
- Renderer-to-main IPC authorization bypasses.
- Exposure of executable paths, attachment paths, native session references, Provider API keys, or Skill file paths to renderer code.
- Exposure of executable paths, attachment paths, native session references, CLI API keys, or Skill file paths to renderer code.
- Arbitrary shell execution, arbitrary file read/write, unsafe navigation, or unsafe installer behavior.
- Attachment validation, private storage, checksum, or preview isolation failures.
- Credential storage or Provider configuration failures that expose secrets.
- Credential storage failures that expose secrets.
## Out of Scope
+57 -17
View File
@@ -11,15 +11,15 @@ The supported product surface is the packaged Electron application. The frontend
| Component | Responsibility | Primary evidence |
| --- | --- | --- |
| Vue renderer | Agent-first multi-session UI, direct-session rename/delete, live direct-chat conclusions and inline execution summaries, group-chat result streaming with an expandable per-Agent/right-side trace panel, per-round navigation, background completion markers, first-run carousel, conversation/Skill/image/knowledge composer UI, bounded attachment-preview cache, theme/i18n, user confirmation and input collection | `frontend/src/App.vue`, `frontend/src/components/RunTracePanel.vue`, `frontend/src/composables/useAttachmentPreviews.js`, `frontend/src/desktop.js`, `frontend/src/knowledgeBaseCatalog.js` |
| Electron preload | Exposes narrow workspace, Human Gate, Cloud control, installer/Skill, attachment, Provider, and knowledge-source methods through `window.meldworkDesktop` only to a local `file:` document; Channel ingress has no renderer API | `desktop/src/preload.cjs` |
| Electron main | Trust boundary for IPC authorization and shutdown gating, persistence, attachment import/preview, Connector registration, Skill and knowledge-source validation, local/Cloud execution, Channel receivers, navigation, sanitized run events, operating-system notifications, Provider storage, and installer control | `desktop/src/main.cjs` |
| Electron preload | Exposes narrow workspace, Human Gate, Cloud control, installer/Skill, attachment, and knowledge-source methods through `window.meldworkDesktop` only to a local `file:` document; Channel ingress has no renderer API | `desktop/src/preload.cjs` |
| Electron main | Trust boundary for IPC authorization and shutdown gating, persistence, attachment import/preview, Connector registration, Skill and knowledge-source validation, local/Cloud execution, Channel receivers, navigation, sanitized run events, operating-system notifications, and installer control | `desktop/src/main.cjs` |
| Local workspace | Persists Meldwork conversations, final replies, compact evidence capsules, safe message metadata, task-scoped group Sessions, conversation-scoped direct Sessions, legacy Session migration, Agent-specific transcript deltas, Agent preferences/runtime evidence, and active run coordination | `desktop/src/workspace/local-workspace.cjs` |
| V4 orchestration | Validates frozen snapshots, phase-aware slots, typed collaboration receipts, negotiated responsibility graphs, work receipts, synthesis recovery, verification, and idempotent commits | `desktop/src/collaboration/orchestration-v4-records.cjs`, `desktop/src/workspace/local-workspace-message-submission.cjs`, `desktop/src/workspace/local-workspace-auto-runner.cjs` |
| V4 orchestration | Validates frozen snapshots, phase-aware slots, typed collaboration receipts, negotiated responsibility graphs, work receipts, synthesis recovery, verification, and idempotent commits | `desktop/src/collaboration/orchestration-v4-records.cjs`, `desktop/src/workspace/local-workspace-message-submission.cjs`, `desktop/src/workspace/local-workspace-auto-runner.cjs` and its `local-workspace-auto-runner-*.cjs` parts (see Code Map) |
| Run Ledger | Atomically checkpoints bounded, sanitized per-run and per-Agent state for stop/crash recovery, marks nonterminal runs and Agent attempts interrupted after restart, supports idempotent reconciliation of missing terminal Agent messages, and purges records with their conversation | `desktop/src/runs/run-ledger.cjs` |
| Context Pack and delivery stores | Capture immutable, content-addressed Task inputs, attempt-specific approved prompts, exact outbound fingerprints, source hashes, target scope, and native Session provenance | `desktop/src/collaboration/context-pack-store.cjs`, `desktop/src/workspace/local-workspace-context-packs.cjs` |
| Outcome store | Persists typed Artifacts, Evidence, Findings, and Adoption records separately from diagnostic reasoning and tool summaries | `desktop/src/collaboration/outcome-store.cjs`, `desktop/src/collaboration/outcome-records.cjs` |
| Human Gate store | Persists permission, budget, and decision requests and their terminal user/system decisions without exposing raw request payloads to the renderer | `desktop/src/gates/human-gate-store.cjs`, `desktop/src/gates/human-gate-coordinator.cjs` |
| Scheduling and failure policy | Enforces run budgets, retry policy, per-Agent controls, and global/workspace scheduling | `desktop/src/runs/run-scheduler.cjs`, `desktop/src/runs/failure-policy.cjs` |
| Scheduling and failure policy | Enforces run budgets, per-Agent controls, and one writer at a time within a conversation (conversations never wait for each other); classifies failures (sign-in, rate limit, network, provider, context overflow) and decides retries with `Retry-After`; stops a silent Agent through the invocation watchdog | `desktop/src/runs/run-scheduler.cjs`, `desktop/src/runs/failure-policy.cjs`, `desktop/src/runs/failure-signals.cjs`, `desktop/src/runs/invocation-watchdog.cjs` |
| Agent Connector registry/runtime | Validates approved manifests and instances, separates CredentialRefs, records Connector/upstream versions, and reduces versioned idempotent Run Events | `desktop/src/agents/connectors/agent-connector-manifest.cjs`, `desktop/src/agents/connectors/agent-connector-registry.cjs`, `desktop/src/agents/connectors/agent-connector-runtime.cjs` |
| Attachment store | Imports validated local images, media, documents, code/configuration files, Office files, and archives into private app-owned storage, verifies metadata/checksums on read, and removes unreferenced entries | `desktop/src/attachments/attachment-store.cjs`, `desktop/src/attachments/image-dimensions.cjs` |
| Local Skill catalog | Scans bounded known roots per Agent and returns sanitized Skill coordinates without exposing paths or contents | `desktop/src/skills/local-skill-catalog.cjs` |
@@ -30,9 +30,53 @@ The supported product surface is the packaged Electron application. The frontend
| Knowledge-source store | Persists only the user-selected Obsidian Vault path; Feishu and DingTalk credentials remain owned by their local CLIs | `desktop/src/knowledge/knowledge-base-store.cjs` |
| CLI adapters | Detect and invoke supported local Agent executables with per-Agent arguments and constrained child environments | `desktop/src/agents/cli/cli-adapters.cjs` |
| Agent installer | Detects Agents and runs allowlisted npm packages or official installer scripts after user confirmation | `desktop/src/agents/installer/agent-installer.cjs` |
| Provider store | Validates Provider metadata and encrypts the API key with Electron `safeStorage` | `desktop/src/providers/provider-store.cjs` |
| Managed OpenClaw runtime | Creates isolated local configuration and a restricted tool policy for OpenClaw | `desktop/src/agents/cli/openclaw-runtime.cjs` |
## Code Map
The workspace is composed of small modules around a thin coordinator. A module either owns one piece of state or is a pure function of its inputs; each has a test file with the same name under `desktop/test/`.
**One Agent invocation** (`desktop/src/workspace/local-workspace-agent-invocation.cjs`) is the run lifecycle: lease, run, race against stop and timeout, record the result. It delegates to:
| Module | Owns |
| --- | --- |
| `invocation-session.cjs` | The native Agent session of one invocation: resume, adopt a new id, discard and rebuild, record the turn |
| `invocation-outputs.cjs` | Workspace snapshot before a writable turn, media import and artifact descriptors after it |
| `invocation-prompt.cjs` | The prompt one Agent receives (frozen, direct chat, group) |
| `invocation-budget.cjs` | Usage recorded against the run budget |
| `invocation-failure.cjs` | Why an invocation ended (stopped, timed out, interrupted, cancelled, failed) and the hidden error metadata |
| `desktop/src/runs/invocation-watchdog.cjs` | Startup, inactivity and stall windows; liveness versus substantive progress |
| `desktop/src/agents/agent-permission-policy.cjs` | Automatic answers to permission requests; a read-only turn only approves side-effect-free tools |
| `desktop/src/collaboration/collaboration-receipt.cjs` | Parsing and normalising the control receipt at the end of a turn (lenient for natural discussion, strict for phased V4) |
| `desktop/src/collaboration/answer-marker-filter.cjs` | Hides control markers while an answer streams |
**Prompts** live in `desktop/src/workspace/prompts/`: `natural-discussion-prompt.cjs` (named sections: brief, writer, owner, routing, receipt), `manual-reply-prompt.cjs` (concurrent replies in a group), and `prompt-contracts.cjs` (the language and deliverable contracts shared by all of them). Direct chats are built by `directPromptFor` in `local-workspace-context.cjs`.
**The automatic run** is `LocalWorkspaceAutoRunner`; its methods are grouped into files by what they do and assembled with `Object.assign` onto the class prototype:
| File | Responsibility |
| --- | --- |
| `local-workspace-auto-runner.cjs` | Constructor, `start` and `resume`, run-level helpers |
| `…-natural-discussion.cjs`, `…-natural-decisions.cjs` | The production path: transcript, prompts, the sequential and agent-led loops, and what happens after each round (owner takeover, ignored routes, peer blockers, missing decisions) |
| `…-v4-discussion.cjs`, `…-v4-phase.cjs`, `…-v4-results.cjs` | The phased V4 run, one phase, and turning results into receipts and commits |
| `…-v4-context.cjs`, `…-v4-delivery.cjs`, `…-v4-synthesis.cjs`, `…-v4-outcomes.cjs` | What a run freezes and shares, how context reaches each Agent, synthesis recovery, and the receipts, review issues, convergence and acceptance checks |
| `…-recovery.cjs` | Retries, pausing a failed Agent for the run, unauthorized-session recovery |
| `…-collaboration.cjs` | Collaboration records written as a run progresses |
| `…-task-graph.cjs`, `…-rounds.cjs` | V3 task-graph and V2 round runs, kept only to resume older runs |
Splitting by file did not change any method; the class still shares one `this`. New behaviour should go into a small module with its own tests and be called from the group that owns the topic, not appended to a large method.
## Reliability Rules
- Conversations are independent. A running chat, direct or group, never makes an Agent in another conversation wait, even when both use the same folder and the same Agent. The writer lease is scoped to the conversation; inside one conversation writers still take turns.
- A failure pauses an Agent for the current run only. Group membership is never rewritten because an Agent failed; a group whose members were all removed by an older build is rebuilt from the Agents that replied in it.
- Only a definitive sign-in failure marks credentials missing. A refusal that could be a proxy or a tool's own output is retried once and reported as a provider failure.
- An Agent that was available stays available through an inconclusive readiness probe for ten minutes. A definitive answer applies immediately.
- A stopped or failed turn keeps a healthy long-lived ACP process and its native session. The process is replaced only when it ignores cancellation or the protocol breaks.
- Unknown ACP traffic (banners, new update kinds, unknown notifications) is ignored; unknown requests are answered with "method not found". Malformed JSON, invalid responses and invalid permission requests still fail closed.
- Messages in a group topic carry a dispatch number (`turnSeq`) assigned before the Agent starts, so live and saved replies share one order. Failure and stop notices take the turn's number.
- A discussion does not end on a malformed receipt, an absent recipient, a peer reporting a blocker or an owner who forgot the decision: those are discarded, handed to the owner, or asked for once.
## Concurrent Collaboration V4
- A group with multiple selected Agents in `manual` mode runs as one concurrent batch. Harness freezes every selected Agent's task snapshot and delivery plan before any Scheduler lease, preserves all selected members, and publishes the batch in stable member order after the barrier. Direct conversations, one-Agent targets, and regeneration keep their existing execution paths.
@@ -45,11 +89,11 @@ Automatic participant selection from a larger roster, production remote Agents,
## Trust Boundaries
1. The renderer is not trusted with Node.js, Agent executable paths, native Agent session IDs or storage paths, attachment paths, Skill paths/contents, raw Provider credentials after saving, arbitrary filesystem reads, or arbitrary process execution. User-selected conversation workdirs and the user-selected Obsidian Vault path are intentional UI-visible paths; pasted image bytes use one exact import payload.
1. The renderer is not trusted with Node.js, Agent executable paths, native Agent session IDs or storage paths, attachment paths, Skill paths/contents, raw CLI credentials, arbitrary filesystem reads, or arbitrary process execution. User-selected conversation workdirs and the user-selected Obsidian Vault path are intentional UI-visible paths; pasted image bytes use one exact import payload.
2. Preload exposes only named IPC methods. Main validates that every request comes from the current main frame and exact bundled frontend file.
3. Main owns local files, OS dialogs, attachment validation/storage, Skill and knowledge-source discovery/revalidation, Connector approval/registration, Agent discovery, child processes, Cloud observation, Channel authentication, installer downloads, and credential encryption.
4. Agent CLIs are external processes. They receive a selected working directory, a constrained environment, permission flags, main-resolved image paths, target-scoped immutable Skill snapshots, and validated knowledge snapshots or read-only hints after capability checks.
5. Provider, Agent Connector, Cloud Connector, Channel Connector, and installer destinations are external trust boundaries. Provider URLs are user supplied and validated; manifests and Connector instances fail closed unless explicitly approved; Channel signatures and Workspace scope are verified before Task delivery.
5. Agent Connector, Cloud Connector, Channel Connector, and installer destinations are external trust boundaries. Manifests and Connector instances fail closed unless explicitly approved; Channel signatures and Workspace scope are verified before Task delivery.
6. Harness events expose only allowlisted status, conclusion deltas, reasoning summaries, plans, tool lifecycle summaries, bounded result metadata, and source message IDs. Raw chain-of-thought, commands, executable paths, native session references, credentials, unrestricted stdout/stderr, and arbitrary tool payloads remain outside the renderer and shared Agent context.
## Local Data
@@ -61,7 +105,6 @@ Automatic participant selection from a larger roster, production remote Agents,
| Immutable Context Packs, delivery records, content blobs, typed outcomes, and Human Gates | Electron user data under `meldwork-private/` | Content-addressed or atomically written main-only records; renderer snapshots expose only bounded IDs, provenance summaries, budgets, and pending Gate choices |
| Cloud operations and Channel Inbox | Electron user data under `meldwork-private/` | Private `0600` stores keep opaque remote references, idempotency state, signed-event-derived Task data, delivery cursors, Task mappings, and outbound audit records; raw signatures, headers, credentials, and complete IM history are not exposed to the renderer |
| Imported attachments | Electron user data under `attachments/` | App-owned copy; `0700` directories, `0600` files, type/MIME/size validation, image-dimension checks where applicable, metadata checksum, ID-only renderer access |
| Provider metadata and encrypted key | Electron user data: `meldwork-provider.json` | API key encrypted with `safeStorage`; file written atomically with mode `0600` |
| Knowledge-source preference | Electron user data: `meldwork-knowledge-base.json` | Stores the selected absolute Obsidian Vault path only; written atomically as a private file |
| Managed OpenClaw state/config | Electron user data under `openclaw-managed/` | Per-scope directories, mode `0700`; config mode `0600`; API key passed by child environment |
| Theme, locale, and onboarding completion | Renderer `localStorage` | Non-secret UI preferences |
@@ -74,7 +117,6 @@ There is no Meldwork-hosted remote data store or application account namespace.
- Agent CLIs may call their own configured model providers.
- Selected attachments and Skill-influenced prompts may be sent by an Agent CLI to its configured model provider after local validation and capability checks.
- Compatible Agents may receive the user-configured Provider URL/model/key from main.
- Feishu and DingTalk readiness probes may invoke their configured local CLIs; a selected Agent may then use those CLI connections with read-only instructions.
- An explicit Obsidian selection is searched and fetched through the main-owned read-only filesystem Connector, then captured as an immutable snapshot or explicit live reference before Agent invocation.
- Approved Agent Connectors may contact only their declared outbound destinations and receive main-owned CredentialRefs rather than renderer-provided secrets.
@@ -86,6 +128,7 @@ There is no Meldwork-hosted remote data store or application account namespace.
## Known Risks And Assumptions
- Writable conversations that share a folder are not coordinated. Two Agents in different conversations can edit the same files at the same time, as two terminals in one directory can. Output capture compares `.meldwork-output/` before and after a run, so when two conversations produce deliverables in the same folder at the same time, one may attach or record a file the other wrote.
- Installer recipes use mutable upstream packages or scripts and currently do not verify a pinned digest or signature (`desktop/src/agents/installer/agent-installer.cjs`).
- Conversation history is local but not application-encrypted (`desktop/src/workspace/local-workspace.cjs`).
- Sanitized Run Ledger checkpoints are local and private but not application-encrypted. The store assumes one Electron main-process writer and recovers only the last persisted bounded checkpoint, not every in-memory event (`desktop/src/runs/run-ledger.cjs`).
@@ -95,7 +138,7 @@ There is no Meldwork-hosted remote data store or application account namespace.
- Obsidian selections use a main-owned read-only Connector with bounded retrieval, immutable snapshots, and citation hash verification. Generic Feishu and DingTalk access remains CLI-owned and prompt-mediated; their authentication and document permissions are not controlled by Meldwork. Notion, Confluence, Google Drive, and SharePoint are reference-only planned sources and are not active Connectors.
- Native image support is currently limited to Codex, Hermes, and OpenCode, with different per-run limits; mixed groups fail before execution when they cannot receive equal image context (`desktop/src/agents/cli/cli-adapters.cjs`).
- Read-only enforcement is owned by the Obsidian Connector for captured selections. Agent workspace and CLI-mediated remote access still depends partly on each upstream CLI honoring its permission flags; Meldwork adds per-CLI hardening where available (`desktop/src/agents/cli/cli-adapters.cjs`).
- Provider requests and native Agent authentication are external dependencies; Meldwork cannot guarantee their availability or data handling.
- Native Agent authentication and model requests are external dependencies; Meldwork cannot guarantee their availability or data handling.
- Agent/Cloud/Channel Connector contracts validate provenance, scope, and idempotency, but the external implementation must honor its CredentialRef, outbound destination, cancellation, and signature-verification obligations. The production build registers no Cloud or Channel Connector by default.
- macOS packaging uses ad-hoc signing for local validation, not a notarized public release (`desktop/scripts/after-pack.cjs`).
@@ -107,10 +150,7 @@ There is no Meldwork-hosted remote data store or application account namespace.
## Related Documents
- [Flows](docs/flows.md)
- [Permissions](docs/permissions.md)
- [Variables And Secrets](docs/variables.md)
- [Test Coverage](docs/tests.md)
- [Automation](docs/automation.md)
- [Public MVP Release Checklist](docs/public-mvp-release.md)
- [macOS Signing And Notarization](docs/macos-signing.md)
- [Contributing](CONTRIBUTING.md)
- [Security Policy](SECURITY.md)
- [Repository Guidelines For Agents](AGENTS.md)
- [Changelog](CHANGELOG.md)
-1
View File
@@ -1 +0,0 @@
{"mode": "chat"}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meldwork-desktop",
"version": "0.1.5",
"version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meldwork-desktop",
"version": "0.1.5",
"version": "0.1.6",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.23.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "meldwork-desktop",
"version": "0.1.5",
"version": "0.1.6",
"private": true,
"description": "A local-first desktop workspace for multi-Agent collaboration",
"author": "Ryder-MHumble",
+1 -1
View File
@@ -5,7 +5,7 @@ const TEST_SUITES = Object.freeze({
agents: ['agents'],
attachments: ['attachments'],
collaboration: ['collaboration'],
core: ['channels', 'knowledge', 'media', 'providers', 'skills'],
core: ['channels', 'knowledge', 'media', 'skills'],
runs: ['runs'],
security: ['security', 'shell'],
workspace: ['workspace', 'gates'],
@@ -265,6 +265,7 @@ module.exports = {
AGENT_COMPATIBILITY,
assessAgentVersion,
capabilityProbes,
compareReleaseVersions,
extractAgentVersion,
identifyAgentVersion,
}
+6 -1
View File
@@ -442,8 +442,13 @@ function importAgentOutputs(input, attachmentStore) {
const realPath = fs.realpathSync(reportedPath)
const stat = fs.statSync(realPath)
const extension = path.extname(realPath).toLowerCase()
// A path the Agent merely mentioned (a file it read, the user's input,
// a staged attachment) is not an output. Only files written during this
// run outside the input staging folder are imported.
if (!stat.isFile() || !SUPPORTED_EXTENSIONS.has(extension)
|| stat.size > 64 * 1024 * 1024 || !isInside(workdirRealPath, realPath)) continue
|| stat.size > 64 * 1024 * 1024 || !isInside(workdirRealPath, realPath)
|| realPath.split(path.sep).includes('.meldwork-input')
|| stat.mtimeMs < startedAt - 2000) continue
reportedCandidates.push({
name: path.basename(realPath), path: realPath, size: stat.size,
mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs,
@@ -0,0 +1,77 @@
const { createHash } = require('node:crypto')
const { canonicalJson } = require('../collaboration/context-pack-records.cjs')
function sha256(value) {
return createHash('sha256').update(String(value || '')).digest('hex')
}
// Tool kinds an Agent may use without approval during a read-only turn.
const READ_ONLY_SAFE_TOOL_KINDS = new Set(['read', 'search', 'fetch', 'think'])
// Automatic answer to an Agent's permission request when the run is not
// waiting for a person. Returns the response to send, `null` when the request
// is refused but the Agent offered nothing to refuse with, or `undefined`
// when no automatic answer exists and the caller must ask the user.
//
// A read-only turn stays read-only even when the Agent asks: only
// side-effect-free tools are approved, while edits, deletes, moves, commands
// and mode switches are refused.
function automaticPermissionResponse(request, { allowWrite }) {
const options = Array.isArray(request?.options) ? request.options : []
if (!allowWrite && !READ_ONLY_SAFE_TOOL_KINDS.has(request?.toolCall?.kind)) {
const rejection = options.find(candidate => candidate?.kind === 'reject_once')
|| options.find(candidate => candidate?.kind === 'reject_always')
return rejection?.optionId ? { status: 'rejected', optionId: rejection.optionId } : null
}
const option = options.find(candidate => candidate?.kind === 'allow_once')
|| options.find(candidate => candidate?.kind === 'allow_always')
|| options[0]
return option?.optionId ? { status: 'approved', optionId: option.optionId } : undefined
}
// A person's decision applies only to the exact request it was made for.
function matchesResumedPermission(request, resumedGate) {
if (!request || !resumedGate?.request) return false
try {
if (canonicalJson(request) !== canonicalJson(resumedGate.request)) return false
} catch {
return false
}
const selected = request.options?.find(option => option.optionId === resumedGate.optionId)
if (!selected) return false
return resumedGate.status === 'approved'
? selected.kind.startsWith('allow_')
: selected.kind.startsWith('reject_')
}
function permissionContinuationRequestId(requestHash, binding) {
return sha256(canonicalJson({
requestHash,
sessionRefHash: binding.sessionRefHash,
sessionProvenanceHash: binding.sessionProvenanceHash,
}))
}
// A resumed permission decision must belong to the native session the
// request came from; anything else is refused before the Agent runs.
function assertResumedPermissionBinding(resumed, currentBinding, sessionRef) {
const requestHash = sha256(canonicalJson(resumed.request))
const persistedBinding = {
sessionRefHash: resumed.sessionRefHash,
sessionProvenanceHash: resumed.sessionProvenanceHash,
}
if (resumed.requestId !== permissionContinuationRequestId(requestHash, persistedBinding)
|| resumed.requestHash !== requestHash
|| resumed.sessionProvenanceHash !== currentBinding.sessionProvenanceHash
|| (sessionRef && resumed.sessionRefHash !== currentBinding.sessionRefHash)) {
throw new Error('LOCAL_RUN_PERMISSION_REQUEST_MISMATCH')
}
}
module.exports = {
READ_ONLY_SAFE_TOOL_KINDS,
assertResumedPermissionBinding,
automaticPermissionResponse,
matchesResumedPermission,
permissionContinuationRequestId,
}
+33 -2
View File
@@ -1,3 +1,4 @@
const { authFailureCertainty } = require('../runs/failure-signals.cjs')
const AGENT_OUTCOMES = Object.freeze([
'accepted',
'running',
@@ -219,10 +220,25 @@ function terminalAuthenticationDiagnostic(value) {
: ''
}
// True only for explicit sign-in or API-key statements. Ambiguous refusals
// (HTTP 403, proxy or region blocks, "provider rejected") are classified as
// LOCAL_AGENT_ACCESS_DENIED so they are retried instead of signing the Agent out.
function authConfigurationFailure(detail) {
return authFailureCertainty(detail) === 'definitive'
}
function authFailureCode(detail) {
const certainty = authFailureCertainty(detail)
if (certainty === 'definitive') return 'LOCAL_AGENT_AUTH_REQUIRED'
if (certainty === 'ambiguous') return 'LOCAL_AGENT_ACCESS_DENIED'
return ''
}
function failureCategory(code) {
const value = String(code || '')
if (value === 'LOCAL_AGENT_SESSION_INVALID') return 'session'
if (value === 'LOCAL_AGENT_AUTH_REQUIRED') return 'authentication'
if (value === 'LOCAL_AGENT_ACCESS_DENIED') return 'provider'
if (value === 'LOCAL_AGENT_TIMEOUT') return 'timeout'
if (value === 'LOCAL_AGENT_EXECUTION_STOPPED') return 'cancellation'
if (value === 'LOCAL_AGENT_REFUSED') return 'refusal'
@@ -299,9 +315,19 @@ function requireTerminalAgentResult(result) {
throw agentRuntimeError('LOCAL_AGENT_EXECUTION_STOPPED')
}
if (normalized.outcome === 'failed') {
const failure = normalized.failure?.code
let failure = normalized.failure?.code
? normalized.failure
: agentRuntimeFailure('LOCAL_AGENT_PROCESS_FAILED')
const diagnostic = String(normalized.diagnostic || normalized.text || '')
// A structured failure whose diagnostic names an authentication problem is
// an authentication failure even when the CLI reported a generic process
// error, so the catalog can mark the Agent as needing a fresh login.
if (failure.category !== 'authentication' && authConfigurationFailure(diagnostic)) {
failure = agentRuntimeFailure('LOCAL_AGENT_AUTH_REQUIRED')
} else if (failure.code === 'LOCAL_AGENT_PROCESS_FAILED'
&& authFailureCertainty(normalized.diagnostic) === 'ambiguous') {
failure = agentRuntimeFailure('LOCAL_AGENT_ACCESS_DENIED')
}
throw agentRuntimeError(
failure.code,
normalized.diagnostic || normalized.text,
@@ -310,7 +336,10 @@ function requireTerminalAgentResult(result) {
}
const authenticationDiagnostic = terminalAuthenticationDiagnostic(normalized.text)
if (authenticationDiagnostic) {
throw agentRuntimeError('LOCAL_AGENT_AUTH_REQUIRED', authenticationDiagnostic)
throw agentRuntimeError(
authFailureCode(authenticationDiagnostic) || 'LOCAL_AGENT_ACCESS_DENIED',
authenticationDiagnostic,
)
}
return normalized
}
@@ -338,6 +367,8 @@ module.exports = {
agentRuntimeCapabilities,
agentRuntimeError,
agentRuntimeFailure,
authConfigurationFailure,
authFailureCode,
isCodeReviewAgentKind,
isReviewOnlyAgentKind,
normalizeAgentResult,
+150 -12
View File
@@ -1,5 +1,7 @@
const { spawn } = require('node:child_process')
const { createHash } = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const { PassThrough, Readable, Writable } = require('node:stream')
const { AGENT_PROFILES, prepareCommand } = require('./cli-discovery.cjs')
const {
@@ -23,8 +25,24 @@ const { createAcpOutboundPayload } = require('../../collaboration/outbound-paylo
const { redactSecrets } = require('../../security/secret-redaction.cjs')
const ACP_CANCEL_GRACE_MS = 250
const ACP_MAX_LINE_BYTES = 1024 * 1024
// After a stop or timeout the Agent gets this long to answer the cancelled
// prompt. A clean cancellation keeps the long-lived process and its native
// session; only an Agent that ignores cancel is torn down.
const ACP_CANCEL_SETTLE_MS = 3000
const ACP_INITIALIZE_TIMEOUT_MS = 60 * 1000
const ACP_STDERR_TAIL_BYTES = 64 * 1024
// Process identity must not change when only volatile shell values change.
// Version managers such as fnm put a per-shell directory on PATH, so a fresh
// login-shell capture used to restart the ACP process and drop its session.
const ACP_VOLATILE_ENV_KEYS = new Set([
'PATH', 'Path', 'TERM', 'TERM_PROGRAM', 'TERM_PROGRAM_VERSION', 'TERM_SESSION_ID',
'SHLVL', 'PWD', 'OLDPWD', '_', 'TMPDIR', 'COLUMNS', 'LINES', 'SHELL',
'FNM_MULTISHELL_PATH', 'FNM_DIR', 'FNM_VERSION_FILE_STRATEGY', 'FNM_LOGLEVEL',
'FNM_NODE_DIST_MIRROR', 'FNM_ARCH', 'FNM_COREPACK_ENABLED', 'FNM_RESOLVE_ENGINES',
])
const ACP_MAX_LINE_BYTES = 4 * 1024 * 1024
const ACP_MAX_INPUT_BYTES = 16 * 1024 * 1024
const ACP_UNKNOWN_METHOD_NAME_LIMIT = 120
const ACP_MAX_REPLY_BYTES = 10 * 1024 * 1024
const ACP_PERSISTENT_RUNTIME_LIMIT = 16
const ACP_PERMISSION_OPTION_LIMIT = 16
@@ -88,6 +106,10 @@ function acpTransportError(diagnostic) {
return agentExecutionError('LOCAL_AGENT_PROCESS_FAILED', diagnostic)
}
// Returns the message to deliver, or null for traffic that is safe to ignore.
// A newer Agent can send updates and notifications this client does not know;
// failing the whole turn over them would break Meldwork on every Agent upgrade,
// so only messages that decide what is executed or approved stay strict.
function validateAcpInboundMessage(message, validators, replyState, protocolLabel) {
if (!message || typeof message !== 'object' || Array.isArray(message)
|| message.jsonrpc !== '2.0') {
@@ -99,7 +121,8 @@ function validateAcpInboundMessage(message, validators, replyState, protocolLabe
if (message.method === 'session/update' && !hasId) {
const parsed = validators.zSessionNotification.safeParse(message.params)
if (!parsed.success) {
throw acpTransportError(`${protocolLabel} returned invalid session update parameters.`)
replyState.ignoredMessages = (replyState.ignoredMessages || 0) + 1
return null
}
const update = parsed.data.update
if (replyState.collecting !== false
@@ -119,7 +142,23 @@ function validateAcpInboundMessage(message, validators, replyState, protocolLabe
}
return message
}
throw acpTransportError(`${protocolLabel} requested an unsupported client method.`)
// An unknown request still gets the connection's "method not found"
// answer, so the Agent is not left waiting. The SDK logs every request it
// rejects together with its parameters, so only the method name is
// forwarded. An unknown notification needs no answer and is dropped
// before it can reach the SDK's logging.
const validId = typeof message.id === 'string'
|| (Number.isInteger(message.id) && message.id >= 0)
if (hasId && validId) {
return {
jsonrpc: '2.0',
id: message.id,
method: message.method.slice(0, ACP_UNKNOWN_METHOD_NAME_LIMIT),
params: {},
}
}
replyState.ignoredMessages = (replyState.ignoredMessages || 0) + 1
return null
}
if (!Number.isInteger(message.id) || message.id < 0) {
@@ -215,6 +254,12 @@ function boundedAcpStream(
const parseLine = (line) => {
const text = line.toString('utf8').trim()
if (!text) return
// Some CLIs print banners or warnings on stdout next to the protocol.
// Anything that is not an object is not protocol traffic.
if (!text.startsWith('{')) {
replyState.ignoredMessages = (replyState.ignoredMessages || 0) + 1
return
}
let message
try {
message = JSON.parse(text)
@@ -224,6 +269,7 @@ function boundedAcpStream(
const validated = validateAcpInboundMessage(
message, validators, replyState, protocolLabel,
)
if (!validated) return
if (message.method === 'session/request_permission') {
replyState.pendingPermissionRequestIds.add(message.id)
} else if (message.method === 'session/update' && message.params?.update) {
@@ -529,6 +575,32 @@ function persistenceKey(agent, options) {
return `${agent.kind}:${value}`
}
const ACP_IMAGE_MIME_TYPES = new Map([
['.png', 'image/png'], ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'],
['.gif', 'image/gif'], ['.webp', 'image/webp'],
])
const ACP_MAX_IMAGE_BYTES = 10 * 1024 * 1024
// Images travel as ACP content blocks only when the Agent advertises image
// prompts during initialize. Otherwise the turn fails before prompting so the
// adapter can fall back to a transport that accepts image arguments.
function acpImageBlocks(runtime, imageAttachments) {
const images = Array.isArray(imageAttachments) ? imageAttachments : []
if (!images.length) return []
if (runtime.agentCapabilities?.promptCapabilities?.image !== true) {
throw allowAcpSetupFallback(agentExecutionError('LOCAL_AGENT_IMAGE_UNSUPPORTED'))
}
return images.map((filename) => {
const mimeType = ACP_IMAGE_MIME_TYPES.get(path.extname(filename).toLowerCase())
if (!mimeType) throw agentExecutionError('LOCAL_AGENT_IMAGE_UNSUPPORTED')
const stat = fs.statSync(filename)
if (!stat.isFile() || stat.size > ACP_MAX_IMAGE_BYTES) {
throw agentExecutionError('LOCAL_AGENT_IMAGE_LIMIT')
}
return { type: 'image', mimeType, data: fs.readFileSync(filename).toString('base64') }
})
}
function withAcpRuntimeLock(key, callback) {
const previous = acpRuntimeLocks.get(key) || Promise.resolve()
const current = previous.catch(() => {}).then(callback)
@@ -540,14 +612,19 @@ function withAcpRuntimeLock(key, callback) {
}
function runtimeSignature(agent, workdir, spec, profile, childEnv) {
const environment = Object.keys(childEnv).sort().map(key => [key, childEnv[key]])
const environment = Object.keys(childEnv).sort()
.filter(key => !ACP_VOLATILE_ENV_KEYS.has(key))
.map(key => [key, childEnv[key]])
return createHash('sha256').update(JSON.stringify({
kind: agent.kind,
executable: agent.executable,
workdir,
command: spec.command,
args: spec.args,
acpMode: spec.acpMode || '',
// The session mode (read-only plan versus write) is applied on every turn
// with session/set_mode, so it does not identify a process. Anything that
// really needs a separate process (a permission policy in the environment)
// is already part of the environment below.
profileId: profile.profileId,
environment,
})).digest('hex')
@@ -653,8 +730,14 @@ async function createAcpRuntime(input) {
})
child.stderr.on('data', (chunk) => {
try { runtime.activeTurn?.noteActivity?.() } catch { /* activity is best-effort */ }
// Keep only recent stderr. A long-lived process used to keep the first
// megabyte of its whole lifetime, so a new failure was classified from
// stale early lines (for example an old auth warning).
runtime.stderr.push(chunk)
runtime.stderrBytes += chunk.length
if (runtime.stderrBytes <= 1024 * 1024) runtime.stderr.push(chunk)
while (runtime.stderrBytes > ACP_STDERR_TAIL_BYTES && runtime.stderr.length > 1) {
runtime.stderrBytes -= runtime.stderr.shift().length
}
})
runtime.observedStdout = createObservedAcpInput(
child.stdout, runtime.replyState, protocolLabel,
@@ -713,15 +796,42 @@ async function createAcpRuntime(input) {
)
runtime.connection = new ClientSideConnection(() => client, stream)
acpInitializingRuntimes.add(runtime)
const abortSignal = input.options?.signal
const initializeTimeoutMs = Number.isFinite(input.options?.acpInitializeTimeoutMs)
&& input.options.acpInitializeTimeoutMs > 0
? input.options.acpInitializeTimeoutMs
: ACP_INITIALIZE_TIMEOUT_MS
let initializeTimer = null
let abortListener = null
try {
await Promise.race([
// initialize used to wait forever while holding the per-conversation
// lock, leaving the Agent unusable in that conversation until restart.
const initialized = await Promise.race([
runtime.connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }),
runtime.failurePromise,
new Promise((_, reject) => {
initializeTimer = setTimeout(() => reject(agentExecutionError(
'LOCAL_AGENT_PROTOCOL_UNAVAILABLE',
`${protocolLabel} did not answer the ACP initialize request.`,
)), initializeTimeoutMs)
}),
new Promise((_, reject) => {
abortListener = () => reject(agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED'))
if (abortSignal?.aborted) abortListener()
else abortSignal?.addEventListener('abort', abortListener, { once: true })
}),
])
runtime.agentCapabilities = initialized?.agentCapabilities
&& typeof initialized.agentCapabilities === 'object'
? initialized.agentCapabilities
: {}
} catch (error) {
await closeAcpRuntime(runtime)
if (abortSignal?.aborted) throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
throw allowAcpSetupFallback(acpProtocolError(error, childEnv))
} finally {
clearTimeout(initializeTimer)
if (abortListener) abortSignal?.removeEventListener('abort', abortListener)
acpInitializingRuntimes.delete(runtime)
}
return runtime
@@ -769,6 +879,8 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
pendingPermissionCallbacks: new Set(),
}
runtime.activeTurn = turn
runtime.stderr = []
runtime.stderrBytes = 0
runtime.replyState.bytes = 0
runtime.replyState.inputBytes = 0
runtime.replyState.observedInputBytes = 0
@@ -785,6 +897,8 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
runtime.publicSessionRef = visibleSessionRef
}
let promptStarted = false
let promptRequest = null
let caughtError = null
let abortRequested = false
let timeoutRequested = false
let cancelPromise = Promise.resolve()
@@ -844,14 +958,17 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
if (visibleSessionRef && typeof options.onSessionRef === 'function') {
await operation(options.onSessionRef(visibleSessionRef, { transport: 'acp' }))
}
const imageBlocks = acpImageBlocks(runtime, spec.imageAttachments)
runtime.replyState.collecting = true
let promptResult
try {
promptStarted = true
promptResult = await operation(runtime.connection.prompt({
promptRequest = Promise.resolve(runtime.connection.prompt({
sessionId: protocolSessionRef,
prompt: [{ type: 'text', text: prompt }],
prompt: [{ type: 'text', text: prompt }, ...imageBlocks],
}))
promptRequest.catch(() => {})
promptResult = await operation(promptRequest)
} finally {
runtime.replyState.collecting = false
}
@@ -894,6 +1011,7 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
if (!promptStarted && normalized.message !== 'LOCAL_AGENT_SESSION_INVALID') {
allowAcpSetupFallback(normalized)
}
caughtError = normalized
throw normalized
} finally {
clearTimeout(timeout)
@@ -906,7 +1024,25 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
turn.pendingPermissionCallbacks.clear()
runtime.activeTurn = null
runtime.lastUsedAt = Date.now()
if (abortRequested) await settleWithin(cancelPromise, ACP_CANCEL_GRACE_MS)
if (abortRequested) {
await settleWithin(cancelPromise, ACP_CANCEL_GRACE_MS)
// A user stop or watchdog timeout used to destroy the process and its
// native session. Keep both when the Agent honours the cancellation.
let promptSettled = false
// Disposable runtimes are always closed after the turn; only a
// persistent runtime is worth waiting for.
if (promptRequest && !acpDisposableRuntimes.has(runtime)) {
await settleWithin(
promptRequest.then(() => { promptSettled = true }, () => { promptSettled = true }),
ACP_CANCEL_SETTLE_MS,
)
}
if (caughtError && promptSettled && !runtime.closed && !runtime.failureError) {
Object.defineProperty(caughtError, 'acpRuntimeReusable', {
value: true, enumerable: false, configurable: true,
})
}
}
}
}
@@ -970,8 +1106,10 @@ async function runPersistentAcpAgent(agent, prompt, workdir, options, spec, prof
try {
return await runAcpTurn(runtime, prompt, options, spec, profile)
} catch (error) {
acpSessionRuntimes.delete(key)
await closeAcpRuntime(runtime)
if (error?.acpRuntimeReusable !== true) {
acpSessionRuntimes.delete(key)
await closeAcpRuntime(runtime)
}
throw error
} finally {
runtime.active = false
+29 -3
View File
@@ -53,6 +53,17 @@ const MAX_STDOUT_CAPTURE_BYTES = 10 * 1024 * 1024
const MAX_STDERR_CAPTURE_BYTES = 1024 * 1024
const OUTPUT_TRUNCATION_MARKER = Buffer.from('\n[output truncated]\n')
const BROKEN_INPUT_GRACE_MS = 5000
// A step that ends because the model asked for tools is followed by more
// steps (the tools run, then the model continues); only the last step's finish
// ends the run. Ending the process after the first one cut every tool-using
// MiMo or OpenCode turn short.
function stepFinishContinues(event) {
const reason = String(event?.part?.reason || event?.reason || '').toLowerCase()
return /^tool[-_ ]?(calls?|use)$/.test(reason)
}
function createBoundedOutputCapture(maxBytes) {
const headLimit = Math.floor(maxBytes / 2)
const tailLimit = maxBytes - headLimit
@@ -125,7 +136,6 @@ async function runAgent(agent, prompt, workdir, options = {}) {
const spec = invocation(agent.kind, agent.executable, workdir, sessionRef, {
platform,
sandbox: options.sandbox,
provider: options.provider,
attachments: options.attachments,
hermesAcpAvailable,
invocationTransport: options.invocationTransport,
@@ -246,6 +256,7 @@ async function runAgent(agent, prompt, workdir, options = {}) {
let stopRequested = false
let timeoutRequested = false
let inputError = null
let brokenInputTimer = null
let timeout
let forceKillTimeout
let forceSettleTimeout
@@ -295,6 +306,7 @@ async function runAgent(agent, prompt, workdir, options = {}) {
clearTimeout(forceSettleTimeout)
clearTimeout(terminalKillTimer)
clearTimeout(terminalForceKillTimer)
clearTimeout(brokenInputTimer)
options.signal?.removeEventListener('abort', abort)
callback()
}
@@ -316,7 +328,8 @@ async function runAgent(agent, prompt, workdir, options = {}) {
(['workbuddy', 'claude', 'qwen'].includes(agent.kind) && event.type === 'result')
|| (agent.kind === 'pi' && event.type === 'agent_end')
|| (agent.kind === 'codex' && event.type === 'turn.completed')
|| (['mimo', 'opencode'].includes(agent.kind) && event.type === 'step_finish')
|| (['mimo', 'opencode'].includes(agent.kind) && event.type === 'step_finish'
&& !stepFinishContinues(event))
|| (agent.kind === 'gemini' && event.type === 'result')
|| (agent.kind === 'kimi' && event.type === 'session.resume_hint')
)
@@ -385,10 +398,23 @@ async function runAgent(agent, prompt, workdir, options = {}) {
})
child.stdin.on('error', (error) => {
if (settled || inputError) return
inputError = agentExecutionError(
const sendFailure = () => agentExecutionError(
'LOCAL_AGENT_SEND_FAILED',
redactChildSecrets(error?.message || error, childEnv),
)
// A closed pipe usually means the CLI exited before it read the whole
// prompt (expired sign-in, rejected flag). Its own output says why, so
// let the process finish and report that. Only a process that stays
// alive without reading its input is a send failure.
if (['EPIPE', 'ERR_STREAM_DESTROYED', 'ECONNRESET'].includes(error?.code)) {
brokenInputTimer ||= setTimeout(() => {
if (settled || inputError) return
inputError = sendFailure()
abort()
}, BROKEN_INPUT_GRACE_MS)
return
}
inputError = sendFailure()
abort()
})
child.on('error', error => finish(() => rejectTerminal(
+24 -3
View File
@@ -209,6 +209,23 @@ function agentCommands(kind, options = {}) {
return commands
}
// The WorkBuddy app ships its CLI with only the lite bundle, which the
// launcher loads solely when this variable is set. Without it every headless
// call, `--help` included, exits with MODULE_NOT_FOUND. Installs that carry a
// regular bundle launch exactly as before.
function launchEnvironment(kind, executable, options = {}) {
if (kind !== 'workbuddy' || !executable) return {}
const { pathApi } = runtimeOptions(options)
try {
const dist = pathApi.join(pathApi.dirname(fs.realpathSync(executable)), '..', 'dist')
const shipped = name => fs.existsSync(pathApi.join(dist, name))
if (shipped('codebuddy-lite-wb.mjs') && !shipped('codebuddy-headless.js') && !shipped('codebuddy.js')) {
return { CODEBUDDY_FORCE_LITE_WB_BUNDLE: '1' }
}
} catch { /* an unreadable layout keeps the default launch */ }
return {}
}
function executableExtensions(options = {}) {
const { platform, env } = runtimeOptions(options)
if (platform !== 'win32') return ['']
@@ -333,9 +350,12 @@ async function probeAgentCapabilities(kind, executable, options = {}) {
const runtime = runtimeOptions(options)
const { env, execFileFn, platform } = runtime
const prepareCommandFn = options.prepareCommandFn || prepareCommand
const childEnv = options.childEnv || {
...systemChildEnvironment(env, platform),
PATH: searchPath(options),
const childEnv = {
...(options.childEnv || {
...systemChildEnvironment(env, platform),
PATH: searchPath(options),
}),
...launchEnvironment(kind, executable, options),
}
let identity = ''
try {
@@ -513,6 +533,7 @@ module.exports = {
AGENT_PROFILES,
ALLOWED_KINDS,
detectAgents,
launchEnvironment,
prepareCommand,
probeAgentCapabilities,
resolveExecutable,
+6 -23
View File
@@ -87,6 +87,9 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
eventTransport: 'acp',
fallbackTransport: 'legacy',
fallbackSessionPolicy: 'invalidate',
// Hermes ACP advertises image prompts; send images as content blocks
// instead of silently dropping them from a text-only prompt.
...(attachments.length ? { imageAttachments: attachments } : {}),
}
}
return {
@@ -95,8 +98,6 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
'chat',
'--quiet',
...(options.sandbox === 'workspace-write' ? ['--yolo'] : []),
...(options.provider?.id ? ['--provider', options.provider.id] : []),
...(options.provider?.model ? ['--model', options.provider.model] : []),
...(legacySessionRef ? ['--resume', legacySessionRef] : []),
...(attachments[0] ? ['--image', attachments[0]] : []),
'--query',
@@ -144,8 +145,10 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
'--print',
'--output-format', 'stream-json',
'--include-partial-messages',
// Workspace write access means accepting file edits in the working
// directory. Skipping every permission check would also let the CLI
// run any command anywhere on the machine without confirmation.
'--permission-mode', options.sandbox === 'workspace-write' ? 'acceptEdits' : 'plan',
...(options.sandbox === 'workspace-write' ? ['--dangerously-skip-permissions'] : []),
...(maxTurns ? ['--max-turns', String(maxTurns)] : []),
...(sessionRef ? ['--resume', sessionRef] : []),
],
@@ -218,24 +221,6 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
'--include-partial-messages',
'--verbose',
'--permission-mode', options.sandbox === 'workspace-write' ? 'acceptEdits' : 'plan',
...(options.provider?.id === 'anthropic' ? [
'--settings', JSON.stringify({
// Native settings.env overrides process env. The helper keeps the key out of argv.
apiKeyHelper: (options.platform || process.platform) === 'win32'
? 'powershell.exe -NoLogo -NoProfile -NonInteractive -Command "[Console]::Write([Environment]::GetEnvironmentVariable(\'MELDWORK_PROVIDER_API_KEY\'))"'
: 'printf \'%s\' "$MELDWORK_PROVIDER_API_KEY"',
env: {
CLAUDE_CODE_USE_BEDROCK: '0', CLAUDE_CODE_USE_VERTEX: '0',
CLAUDE_CODE_USE_FOUNDRY: '0', CLAUDE_CODE_USE_MANTLE: '0',
ANTHROPIC_BASE_URL: options.provider.baseUrl,
ANTHROPIC_API_KEY: '', ANTHROPIC_AUTH_TOKEN: '', CLAUDE_CODE_OAUTH_TOKEN: '',
ANTHROPIC_CUSTOM_HEADERS: '',
ANTHROPIC_DEFAULT_OPUS_MODEL: '', ANTHROPIC_DEFAULT_SONNET_MODEL: '',
ANTHROPIC_DEFAULT_HAIKU_MODEL: '',
},
}),
] : []),
...(options.provider?.model ? ['--model', options.provider.model] : []),
...(sessionRef ? ['--resume', sessionRef] : []),
],
promptArg: true,
@@ -248,8 +233,6 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
'--output-format', 'stream-json',
'--include-partial-messages',
'--approval-mode', options.sandbox === 'workspace-write' ? 'auto-edit' : 'plan',
...(options.provider?.id === 'openai' ? ['--auth-type', 'openai'] : []),
...(options.provider?.model ? ['--model', options.provider.model] : []),
...(sessionRef ? ['--resume', sessionRef] : []),
],
promptArg: true,
+29 -7
View File
@@ -172,17 +172,38 @@ function stepFinishOutcome(reason) {
return 'partial'
}
function structuredResultDiagnostic(event) {
const parts = []
if (typeof event?.error === 'string' && event.error.trim()) parts.push(event.error.trim())
else if (event?.error?.message) parts.push(String(event.error.message))
if (typeof event?.result === 'string' && event.result.trim()) parts.push(event.result.trim())
if (Array.isArray(event?.errors)) {
parts.push(...event.errors.map(value => String(value || '').trim()).filter(Boolean))
}
return parts.join('\n')
}
function ingestStructuredEvent(state, event) {
if (!event || typeof event !== 'object' || Array.isArray(event)) return
state.seen = true
if (['workbuddy', 'claude', 'qwen'].includes(state.kind)
&& event.type === 'result' && typeof event.result === 'string') {
&& event.type === 'result') {
if (typeof event.session_id === 'string') state.sessionRef = event.session_id
state.failure = null
state.diagnostic = ''
state.text = ''
appendStructuredText(state, event.result, '')
state.outcome = event.is_error === true ? 'partial' : 'completed'
if (typeof event.result === 'string') {
state.failure = null
state.diagnostic = ''
state.text = ''
appendStructuredText(state, event.result, '')
state.outcome = event.is_error === true ? 'partial' : 'completed'
return
}
// An error result without a usable result string still carries the CLI's
// error list; keep it as the failure diagnostic so authentication and
// session problems are classified instead of surfacing as a bare
// LOCAL_AGENT_PROCESS_FAILED.
if (event.is_error === true || String(event.subtype || '').startsWith('error')) {
markStructuredFailure(state, 'LOCAL_AGENT_PROCESS_FAILED', structuredResultDiagnostic(event))
}
return
}
if (structuredEventFailed(event)) {
@@ -885,7 +906,8 @@ function classifyCliOutcome(kind, stdout) {
function structuredCliError(stdout) {
const failed = parseJsonOutputEvents(stdout)
.findLast(event => event?.is_error || event?.subtype?.startsWith('error'))
return String(failed?.error?.message || failed?.result || '').trim()
if (!failed) return ''
return structuredResultDiagnostic(failed)
}
function hermesSessionRef(stderr) {
+18 -15
View File
@@ -1,8 +1,12 @@
const path = require('node:path')
const { searchPath, systemChildEnvironment } = require('./cli-discovery.cjs')
const { launchEnvironment, searchPath, systemChildEnvironment } = require('./cli-discovery.cjs')
const { NETWORK_ENV_KEYS, networkEnvironment } = require('./cli-network-environment.cjs')
const { redactChildSecrets } = require('./cli-runtime-events.cjs')
const { agentRuntimeError, terminalAuthenticationDiagnostic } = require('../agent-runtime-contract.cjs')
const {
agentRuntimeError,
authFailureCode,
} = require('../agent-runtime-contract.cjs')
const { authFailureCertainty, isContextOverflow } = require('../../runs/failure-signals.cjs')
const { validateOpenClawRuntimeGuard } = require('./openclaw-runtime.cjs')
const TERMINATE_GRACE_MS = 500
@@ -29,19 +33,19 @@ function agentExecutionError(code, diagnostic = '') {
return agentRuntimeError(code, diagnostic)
}
function authConfigurationFailure(detail) {
return String(detail || '').split(/\r?\n/).some((line) => {
const text = line.trim().replace(/^(?:error|fatal)\s*:\s*/i, '')
return Boolean(terminalAuthenticationDiagnostic(text))
|| /^(?:(?:invalid|expired|missing|incorrect)\s+(?:api[ _-]?key|(?:access|refresh|auth)[ _-]?token|credentials?)\b|(?:authentication|authorization)\s+(?:failed|required)\b|(?:please\s+)?(?:log|sign)\s+in\b|not\s+logged\s+in\b|select an auth type\b|provider rejected\b)/i.test(text)
|| /^(?:认证|鉴权|登录)(?:失败|过期)|^(?:令牌|凭据|密钥)(?:无效|缺失|已过期)|^请(?:先|重新)登录/.test(text)
})
}
function invalidSessionFailure(detail) {
const text = String(detail || '').trim()
if (!text || authConfigurationFailure(text)
if (!text || authFailureCertainty(text)
|| /\b(?:api|access|refresh|auth|session)[ _-]?token\b/i.test(text)) return false
// A resumed native session that no longer fits the model context cannot be
// continued; rebuilding it with compact continuation context is the fix.
if (isContextOverflow(text)) return true
// Runtime crashes ("thread 'main' panicked ... invalid ...") and parser
// errors mention threads or invalid input without saying anything about the
// stored session. Discarding a healthy session for them loses context.
if (/\bpanicked\b|\bSyntaxError\b|\binvalid JSON\b|\bUnexpected token\b|\bstack backtrace\b/i.test(text)) {
return false
}
return /\b(?:no|unknown|invalid|expired|missing|stale)\s+(?:saved\s+)?(?:session|conversation|thread)\b|\b(?:session|conversation|thread)(?:\s+(?:id|key|reference))?\b[^\n]{0,100}\b(?:not found|does not exist|no longer exists|invalid|unknown|expired|has expired|was deleted|cannot be resumed)\b/i
.test(text)
}
@@ -51,9 +55,7 @@ function failedAgentProcessError(detail, options = {}) {
return agentExecutionError(
options.sessionRef && invalidSessionFailure(detail)
? 'LOCAL_AGENT_SESSION_INVALID'
: authConfigurationFailure(detail)
? 'LOCAL_AGENT_AUTH_REQUIRED'
: 'LOCAL_AGENT_PROCESS_FAILED',
: authFailureCode(detail) || 'LOCAL_AGENT_PROCESS_FAILED',
detail,
)
}
@@ -123,6 +125,7 @@ function childEnvironment(agent, workdir, options, platform) {
...controlled,
...hermesSafetyEnv,
...openCodeSafetyEnv,
...launchEnvironment(agent.kind, agent.executable, { platform }),
PATH: searchPath({ platform, env: source }),
}
}
@@ -425,7 +425,7 @@ function acpPlanSummary(update) {
}
function createAcpRuntimeState() {
return { tools: new Map() }
return { tools: new Map(), reasoning: new Map() }
}
const ACP_TOOL_KINDS = new Set([
@@ -447,10 +447,22 @@ function acpToolOperation(update) {
function acpRuntimeEvents(update, state) {
if (!update || typeof update !== 'object') return []
if (update.sessionUpdate === 'agent_message_chunk' && update.content?.type === 'text') {
return [{ type: 'answer_delta', status: 'running', delta: update.content.text }]
// Close an open reasoning phase once the visible answer starts.
const closed = state?.reasoning?.get('reasoning:acp')?.startsWith('running:')
? reasoningLifecycleEvent(state, 'reasoning:acp', 'completed')
: null
return [
...(closed ? [closed] : []),
{ type: 'answer_delta', status: 'running', delta: update.content.text },
]
}
if (update.sessionUpdate === 'agent_thought_chunk' && update.content?.type === 'text') {
return []
// Thinking is real progress: report a text-free reasoning status so the
// watchdog and the trace know the Agent is working. The private thought
// text itself never leaves the main process.
if (!state?.reasoning) return []
const event = reasoningLifecycleEvent(state, 'reasoning:acp', 'running')
return event ? [event] : []
}
if (update.sessionUpdate === 'tool_call' || update.sessionUpdate === 'tool_call_update') {
const status = runtimeEventStatus(update.status)
+11 -92
View File
@@ -5,7 +5,6 @@ const { isDeepStrictEqual } = require('node:util')
const { atomicWritePrivateFile } = require('../../security/private-file.cjs')
const MANAGED_OPENCLAW_PROVIDER_ID = 'meldwork-desktop'
const READ_ONLY_TOOLS = [
'read', 'web_search', 'web_fetch', 'memory_search', 'memory_get', 'session_status',
]
@@ -37,7 +36,6 @@ const OPENCLAW_RUNTIME_PATH_ENV_KEYS = Object.freeze([
'OPENCLAW_WORKSPACE_DIR',
])
const OPENCLAW_RUNTIME_CREDENTIAL_KEYS = new Set([
'MELDWORK_OPENCLAW_API_KEY',
'MELDWORK_OPENCLAW_NATIVE_API_KEY',
'OPENCLAW_GATEWAY_TOKEN',
])
@@ -173,26 +171,7 @@ function validateFileIdentity(expected) {
}
}
function normalizeProvider(input) {
const apiKey = typeof input?.OPENAI_API_KEY === 'string' ? input.OPENAI_API_KEY.trim() : ''
const baseUrl = String(input?.OPENAI_BASE_URL || '')
.trim()
.replace(/\/+$/, '')
.replace(/\/chat\/completions$/i, '')
const model = String(input?.OPENAI_MODEL || '').trim()
let parsed
try { parsed = new URL(baseUrl) } catch { throw new Error('OPENCLAW_PROVIDER_INVALID') }
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname)
if (!apiKey || apiKey.length > 8192 || isOpenClawSecretReference(apiKey)
|| !model || model.length > 120
|| (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback))
|| parsed.username || parsed.password || parsed.search || parsed.hash) {
throw new Error('OPENCLAW_PROVIDER_INVALID')
}
return { apiKey, baseUrl, model }
}
function runtimePaths({ storageRoot, workdir, sessionRef, configName }) {
function runtimePaths({ storageRoot, workdir, sessionRef, configName, runtimeVersion }) {
if (!path.isAbsolute(storageRoot) || !path.isAbsolute(workdir)) {
throw new Error('OPENCLAW_RUNTIME_INVALID_SCOPE')
}
@@ -204,8 +183,15 @@ function runtimePaths({ storageRoot, workdir, sessionRef, configName }) {
throw new Error('OPENCLAW_RUNTIME_INVALID_SCOPE')
}
const resolvedWorkdir = path.resolve(workdir)
// The managed scope is keyed by session, workspace, and CLI version. OpenClaw
// refuses to start on state written by an older CLI ("Legacy workspace setup
// state requires migration"), so a version-scoped directory gives an upgraded
// CLI a fresh isolated scope instead of a guaranteed startup failure.
const versionSegment = String(runtimeVersion || '').trim()
const scope = crypto.createHash('sha256')
.update(`${sessionRef || 'configure'}\0${resolvedWorkdir}`)
.update(versionSegment
? `${sessionRef || 'configure'}\0${resolvedWorkdir}\0${versionSegment}`
: `${sessionRef || 'configure'}\0${resolvedWorkdir}`)
.digest('hex')
.slice(0, 24)
const runtimeRoot = path.join(resolvedStorageRoot, 'openclaw-managed', scope)
@@ -458,74 +444,8 @@ function normalizedNativeModel(input) {
return model
}
function managedOpenClawOptions({
storageRoot, workdir, sessionRef = '', allowWrite = false, provider,
}) {
const normalized = normalizeProvider(provider)
const configName = allowWrite
? 'openclaw.provider.workspace-write.json'
: 'openclaw.provider.read-only.json'
const runtime = runtimePaths({
storageRoot, workdir, sessionRef, configName,
})
const { resolvedWorkdir, home, state, configPath } = runtime
const modelRef = `${MANAGED_OPENCLAW_PROVIDER_ID}/${normalized.model}`
const gatewayCredentialKey = 'OPENCLAW_GATEWAY_TOKEN'
const gatewayToken = crypto.randomBytes(32).toString('base64url')
const config = {
agents: {
defaults: {
workspace: resolvedWorkdir,
model: { primary: modelRef },
skipBootstrap: true,
},
},
models: {
mode: 'replace',
providers: {
[MANAGED_OPENCLAW_PROVIDER_ID]: {
baseUrl: normalized.baseUrl,
apiKey: {
source: 'env', provider: 'default', id: 'MELDWORK_OPENCLAW_API_KEY',
},
api: 'openai-completions',
models: [{ id: normalized.model, name: normalized.model, input: ['text'] }],
},
},
},
tools: toolPolicy(allowWrite),
gateway: {
mode: 'local',
bind: 'loopback',
auth: {
mode: 'token',
token: { source: 'env', provider: 'default', id: gatewayCredentialKey },
},
controlUi: { enabled: false },
},
}
const credentialKey = 'MELDWORK_OPENCLAW_API_KEY'
const openClawRuntimeGuard = writeRuntimeConfig(runtime, config, {
[credentialKey]: normalized.apiKey,
[gatewayCredentialKey]: gatewayToken,
}, credentialKey)
return {
openClawRuntimeGuard,
env: {
OPENCLAW_HOME: home,
OPENCLAW_STATE_DIR: state,
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_WORKSPACE_DIR: resolvedWorkdir,
[credentialKey]: normalized.apiKey,
[gatewayCredentialKey]: gatewayToken,
},
}
}
function nativeOpenClawOptions({
storageRoot, workdir, sessionRef = '', allowWrite = false, runtime,
storageRoot, workdir, sessionRef = '', allowWrite = false, runtime, openclawVersion,
}) {
const modelRef = String(runtime?.model || '').trim()
const provider = runtime?.provider
@@ -557,7 +477,7 @@ function nativeOpenClawOptions({
? 'openclaw.native.workspace-write.json'
: 'openclaw.native.read-only.json'
const runtimePathsResult = runtimePaths({
storageRoot, workdir, sessionRef, configName,
storageRoot, workdir, sessionRef, configName, runtimeVersion: openclawVersion,
})
const { resolvedWorkdir, home, state, configPath } = runtimePathsResult
const modelConfig = { primary: modelRef }
@@ -618,7 +538,6 @@ function nativeOpenClawOptions({
module.exports = {
configureOpenClawGatewayRuntime,
isOpenClawSecretReference,
managedOpenClawOptions,
nativeOpenClawOptions,
refreshOpenClawRuntimeAfterStartup,
validateOpenClawRuntimeGuard,
@@ -632,8 +632,6 @@ class CloudAgentBridgeStore {
installed: true,
installSupported: false,
installErrorCode: '',
providerCompatible: false,
providerMode: 'connector',
imageAttachmentLimit: 0,
custom: false,
connector: true,
@@ -603,8 +603,6 @@ class LocalAgentConnectors {
installed: true,
installSupported: false,
installErrorCode: '',
providerCompatible: false,
providerMode: 'connector',
imageAttachmentLimit: manifest.inputTypes.includes('image') ? 32 : 0,
custom: !cloud,
connector: true,
@@ -127,9 +127,6 @@ function publicProfile(definition, version = '') {
promptMode: definition.promptMode,
custom: true,
recommended: false,
providerCompatible: false,
providerSupport: 'custom-cli',
providerMode: 'custom',
installed: true,
installSupported: false,
installErrorCode: '',
@@ -4,6 +4,17 @@ const fs = require('node:fs')
const path = require('node:path')
const { promisify } = require('node:util')
const { searchPath } = require('../cli/cli-adapters.cjs')
const { compareReleaseVersions, extractAgentVersion } = require('../agent-compatibility.cjs')
// True when the installed CLI is the recipe's version or newer. A substring
// test treated "11.2.3" as "1.2.3" and offered downgrades for newer CLIs.
function installedVersionCurrent(installedVersion, targetVersion) {
const installed = extractAgentVersion(installedVersion).split('-')[0]
const target = extractAgentVersion(targetVersion).split('-')[0]
const comparison = installed && target ? compareReleaseVersions(installed, target) : null
if (comparison != null) return comparison >= 0
return Boolean(targetVersion) && String(installedVersion || '').includes(targetVersion)
}
const execFileAsync = promisify(execFile)
const DETECTION_CACHE_TTL_MS = 30000
@@ -99,50 +110,17 @@ const NPM_RELEASES = Object.freeze({
const ALLOWED_SCRIPT_URLS = new Set(Object.values(HERMES_RECIPES).map(recipe => recipe.url))
const AGENT_CATALOG = Object.freeze([
{
kind: 'codex', label: 'Codex', recommended: false, providerCompatible: false,
providerSupport: 'responses-required',
},
{
kind: 'claude', label: 'Claude Code', recommended: false, providerCompatible: false,
providerSupport: 'anthropic-required',
},
{
kind: 'hermes', label: 'Hermes', recommended: true, providerCompatible: true,
providerSupport: 'supported',
},
{
kind: 'workbuddy', label: 'WorkBuddy', recommended: true, providerCompatible: true,
providerSupport: 'experimental',
},
{
kind: 'openclaw', label: 'OpenClaw', recommended: true, providerCompatible: true,
providerSupport: 'supported',
},
{
kind: 'pi', label: 'Pi Agent', recommended: false, providerCompatible: false,
providerSupport: 'native-config',
},
{
kind: 'opencode', label: 'OpenCode', recommended: false, providerCompatible: false,
providerSupport: 'native-config',
},
{
kind: 'opencodereview', label: 'OpenCodeReview', recommended: false, providerCompatible: true,
providerSupport: 'supported',
},
{
kind: 'kimi', label: 'Kimi Code', recommended: false, providerCompatible: false,
providerSupport: 'native-config',
},
{
kind: 'mimo', label: 'MiMo Code', recommended: false, providerCompatible: false,
providerSupport: 'native-config',
},
{
kind: 'qwen', label: 'Qwen Code', recommended: false, providerCompatible: true,
providerSupport: 'supported',
},
{ kind: 'codex', label: 'Codex', recommended: false },
{ kind: 'claude', label: 'Claude Code', recommended: false },
{ kind: 'hermes', label: 'Hermes', recommended: true },
{ kind: 'workbuddy', label: 'WorkBuddy', recommended: true },
{ kind: 'openclaw', label: 'OpenClaw', recommended: true },
{ kind: 'pi', label: 'Pi Agent', recommended: false },
{ kind: 'opencode', label: 'OpenCode', recommended: false },
{ kind: 'opencodereview', label: 'OpenCodeReview', recommended: false },
{ kind: 'kimi', label: 'Kimi Code', recommended: false },
{ kind: 'mimo', label: 'MiMo Code', recommended: false },
{ kind: 'qwen', label: 'Qwen Code', recommended: false },
])
function installRecipe(kind, platform) {
@@ -372,6 +350,7 @@ function verifiedRecipeAgent(agent, recipe) {
}
module.exports = {
installedVersionCurrent,
AGENT_CATALOG,
DETECTION_CACHE_TTL_MS,
abortError,
@@ -3,6 +3,7 @@ const fs = require('node:fs')
const { randomUUID } = require('node:crypto')
const { DISPLAY_LIMIT, listLocalAgentSkills } = require('../../skills/local-skill-catalog.cjs')
const {
installedVersionCurrent,
AGENT_CATALOG,
DETECTION_CACHE_TTL_MS,
abortError,
@@ -169,7 +170,8 @@ class AgentInstaller extends EventEmitter {
const recipe = installRecipe(profile.kind, this.platform)
const targetVersion = recipe?.detectedVersion || recipe?.version || ''
const currentVersion = String(agent?.version || '')
const upToDate = Boolean(agent && targetVersion && currentVersion.includes(targetVersion))
const upToDate = Boolean(agent && targetVersion
&& installedVersionCurrent(currentVersion, targetVersion))
let installSupported = Boolean(recipe)
let installErrorCode = ''
if (!recipe) {
@@ -241,7 +243,7 @@ class AgentInstaller extends EventEmitter {
const current = installed.find(agent => agent.kind === profile.kind)
const targetVersion = recipe.detectedVersion || recipe.version
if (current && (current.compatibilityState !== 'compatible'
|| String(current.version || '').includes(targetVersion))) {
|| installedVersionCurrent(current.version, targetVersion))) {
throw installerError('INSTALL_AGENT_ALREADY_INSTALLED')
}
let command = recipe.interpreter || ''
+37 -9
View File
@@ -29,6 +29,12 @@ const OPENCLAW_MODEL_INPUTS = new Set(['text', 'image', 'audio', 'video'])
const MAX_CREDENTIAL_FILE_BYTES = 2 * 1024 * 1024
const MAX_SHELL_ENV_BYTES = 256 * 1024
const SHELL_ENV_CACHE_TTL_MS = 30000
// A login shell that loads nvm/conda/compinit can take several seconds,
// especially right after boot or under load.
const SHELL_ENV_TIMEOUT_MS = 10000
// When the shell does not answer and nothing better is known, retry soon
// instead of pinning the bare app environment for the whole cache window.
const SHELL_ENV_FAILURE_TTL_MS = 5000
const SHELL_ENV_MARKER = '__MELDWORK_NATIVE_ENV_V1__'
const VERTEX_MODEL_REGION_KEY = /^VERTEX_REGION_[A-Z0-9_]{1,120}$/
const CREDENTIAL_ENV_KEYS = Object.freeze({
@@ -49,7 +55,6 @@ const GOOGLE_CLOUD_ENV_KEYS = [
'GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GCLOUD_PROJECT',
'GOOGLE_CLOUD_LOCATION', 'GOOGLE_CLOUD_QUOTA_PROJECT', 'CLOUDSDK_CONFIG',
]
const NATIVE_CONFIG_ROOT_KEYS = new Set(['CODEX_HOME', 'CLAUDE_CONFIG_DIR', 'PI_CODING_AGENT_DIR'])
const RUNTIME_ENV_KEYS = Object.freeze({
codex: ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', 'CODEX_HOME'],
hermes: [
@@ -159,12 +164,11 @@ function textContainsCredential(filename) {
}
}
function nativeCredentialEnvironment(kind, env = process.env, options = {}) {
function nativeCredentialEnvironment(kind, env = process.env) {
const result = {}
const keys = [...(RUNTIME_ENV_KEYS[kind] || []), ...(kind === 'claude'
? Object.keys(env).filter(key => VERTEX_MODEL_REGION_KEY.test(key)) : [])]
for (const key of keys) {
if (options.providerConfigured && !NATIVE_CONFIG_ROOT_KEYS.has(key)) continue
if (typeof env[key] === 'string') result[key] = env[key]
}
return result
@@ -259,7 +263,7 @@ async function queryNativeShellEnvironment(options = {}) {
shell,
[shellName === 'sh' ? '-lc' : '-lic', nativeShellCommand(shellName)],
{
timeout: 5000,
timeout: SHELL_ENV_TIMEOUT_MS,
maxBuffer: MAX_SHELL_ENV_BYTES,
windowsHide: true,
env: childEnv,
@@ -271,13 +275,19 @@ async function queryNativeShellEnvironment(options = {}) {
source: Object.keys(loaded).some(key => key !== 'PATH') ? 'native-shell' : 'process',
}
} catch {
return { env: fallback, source: 'process' }
return { env: fallback, source: 'process', failed: true }
}
}
function publicShellEnvironment({ env, source }) {
return { env, source }
}
let lastGoodShellEnvironment = null
function resolveNativeShellEnvironment(options = {}) {
const useCache = options.cache !== false && !options.execFileFn
if (!useCache) return queryNativeShellEnvironment(options)
if (!useCache) return queryNativeShellEnvironment(options).then(publicShellEnvironment)
const source = options.env || process.env
const platform = options.platform || process.platform
const home = options.home || os.homedir()
@@ -290,9 +300,27 @@ function resolveNativeShellEnvironment(options = {}) {
&& shellEnvironmentCache.expiresAt > now) {
return shellEnvironmentCache.promise
}
const promise = queryNativeShellEnvironment(options)
shellEnvironmentCache = { key, expiresAt: now + SHELL_ENV_CACHE_TTL_MS, promise }
return promise
// The window is measured from when the capture finishes, so a capture that
// used up its whole timeout is not already stale when it returns.
const entry = { key, promise: null, expiresAt: now + SHELL_ENV_CACHE_TTL_MS }
entry.promise = queryNativeShellEnvironment(options).then((result) => {
entry.expiresAt = Date.now() + SHELL_ENV_CACHE_TTL_MS
if (!result.failed) {
if (result.source === 'native-shell') lastGoodShellEnvironment = { key, result }
return publicShellEnvironment(result)
}
// The login shell did not answer (slow rc file, timeout, startup error).
// The bare app environment has none of the user's PATH entries or API
// keys, so an Agent started with it fails with misleading sign-in errors.
// Keep using what the shell last produced for this account instead.
if (lastGoodShellEnvironment?.key === key) {
return publicShellEnvironment(lastGoodShellEnvironment.result)
}
entry.expiresAt = Date.now() + SHELL_ENV_FAILURE_TTL_MS
return publicShellEnvironment(result)
})
shellEnvironmentCache = entry
return entry.promise
}
function nativeCredentialState(kind, options = {}) {
@@ -0,0 +1,81 @@
const { V4_RECEIPT_MARKER_PREFIX } = require('./collaboration-receipt.cjs')
// Control markers an Agent appends to its answer. They steer the harness and
// must never reach the user's screen, even while the answer is still streaming.
const CONSENSUS_MARKERS = Object.freeze([
'[[MELDWORK_CONSENSUS:agree]]',
'[[MELDWORK_CONSENSUS:continue]]',
])
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
// Length of the longest buffer suffix that could still grow into a marker.
function heldSuffixLength(buffer, markers) {
let hold = 0
for (const marker of markers) {
for (let size = Math.min(marker.length - 1, buffer.length); size > hold; size -= 1) {
if (buffer.slice(-size).toLowerCase() === marker.slice(0, size).toLowerCase()) {
hold = size
break
}
}
}
return hold
}
// Streams answer deltas through a marker filter.
// plain nothing to hide: deltas pass through untouched.
// auto hide the legacy consensus markers.
// receipt hide the consensus markers and everything from the start of the
// collaboration receipt on (the receipt is parsed from the final text).
// `push` returns the text that is safe to show now; text that might be the
// start of a marker is held back until the next delta settles it, and
// `flush` releases what is left once the answer has finished.
function createAnswerMarkerFilter({ mode = 'plain' } = {}) {
if (mode === 'plain') {
return { push: delta => String(delta || ''), flush: () => '' }
}
const hideReceipt = mode === 'receipt'
const markers = hideReceipt
? [...CONSENSUS_MARKERS, V4_RECEIPT_MARKER_PREFIX]
: [...CONSENSUS_MARKERS]
const pattern = new RegExp(markers.map(escapeRegExp).join('|'), 'i')
let buffer = ''
let receiptStarted = false
return {
push(delta) {
if (receiptStarted) return ''
buffer += String(delta || '')
let visible = ''
while (buffer && !receiptStarted) {
const match = pattern.exec(buffer)
if (match) {
visible += buffer.slice(0, match.index)
buffer = buffer.slice(match.index + match[0].length)
if (hideReceipt
&& match[0].toLowerCase() === V4_RECEIPT_MARKER_PREFIX.toLowerCase()) {
receiptStarted = true
}
continue
}
const hold = heldSuffixLength(buffer, markers)
visible += hold ? buffer.slice(0, -hold) : buffer
buffer = hold ? buffer.slice(-hold) : ''
break
}
return visible
},
flush(finalText = '') {
const rest = buffer
buffer = ''
if (!hideReceipt) return rest
// A held fragment is real answer text only when the final answer ends
// with it; otherwise it was the start of the (hidden) receipt.
return !receiptStarted && rest && String(finalText).endsWith(rest) ? rest : ''
},
}
}
module.exports = { CONSENSUS_MARKERS, createAnswerMarkerFilter }
@@ -0,0 +1,295 @@
const { canonicalJson } = require('./context-pack-records.cjs')
const { parseLenientTaskDecision } = require('./task-decision.cjs')
const SHA256 = /^[a-f0-9]{64}$/
const V4_AGENT_PHASES = new Set([
'proposal', 'discussion', 'challenge', 'work', 'synthesis', 'verification',
])
const V4_RECEIPT_MARKER_PREFIX = '[[MELDWORK_COLLABORATION'
const V4_RECEIPT_MARKER = /\s*\[\[MELDWORK_COLLABORATION(?:_JSON)?:([\s\S]*?)\]\]/i
const V4_RECEIPT_BLOCK = /\s*\[\[MELDWORK_COLLABORATION(?:_JSON)?\]\]([\s\S]*?)\[\[\/MELDWORK_COLLABORATION(?:_JSON)?\]\]/i
const LEGACY_CONSENSUS_MARKER = /^\s*\[\[MELDWORK_CONSENSUS:(?:agree|continue)\]\]\s*$/gim
const V4_RECEIPT_MAX_TEXT = 800
const V4_RECEIPT_MAX_ITEMS = 16
const V4_RECEIPT_MAX_BYTES = 16 * 1024
function isV4InternalProtocolCommentary(value) {
const text = String(value || '').replace(/\s+/g, ' ').trim()
if (!text || text.length > 4000 || /\[\[MELDWORK_COLLABORATION/i.test(text)) return false
const mentionsControl = /(?:\breceipt\b|structured marker|MELDWORK framework|ExitPlanMode|\u7ed3\u6784\u5316\s*receipt|receipt\s*\u6807\u8bb0)/i.test(text)
const describesInternalProcess = /(?:coordination\/self-introduction|not a code implementation task|no plan file|implementation planning|plan mode|waiting for (?:the )?(?:next|subsequent).*(?:phase|instruction)|\u534f\u4f5c\u534f\u8c03\u7c7b\u6587\u672c\u4ea7\u51fa|\u975e\u4ee3\u7801\u5b9e\u73b0\u4efb\u52a1|\u6267\u884c\u8ba1\u5212\u6d41\u7a0b|\u4e0d\u8c03\u7528\s*ExitPlanMode|\u672c\u8f6e\u4ea4\u4ed8|\u7b49\u5f85\u540e\u7eed.*(?:\u9636\u6bb5|\u6307\u4ee4))/i.test(text)
return mentionsControl && describesInternalProcess
}
function stripV4InternalProtocolTail(value) {
const paragraphs = String(value || '').trim().split(/\n{2,}/)
while (paragraphs.length && isV4InternalProtocolCommentary(paragraphs.at(-1))) {
paragraphs.pop()
}
return paragraphs.join('\n\n').trim()
}
function boundedReceiptText(value, limit = V4_RECEIPT_MAX_TEXT) {
if (typeof value !== 'string' || value.trim().length > limit) return ''
const text = cleanReceiptText(value, limit)
return text && !text.includes('\u0000') ? text : ''
}
function boundedReceiptList(value) {
if (value == null) return []
if (!Array.isArray(value) || value.length > V4_RECEIPT_MAX_ITEMS) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
}
const result = value.map(item => boundedReceiptText(item))
if (result.some(item => !item)) throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
return result
}
// A decision that is still unreadable after normalisation is treated as no
// decision: the orchestrator then asks the owner again instead of failing
// the Agent and removing it from the discussion.
function leniencyTaskDecision(value, summary) {
try {
return { taskDecision: parseLenientTaskDecision(value, { fallbackReason: summary }) }
} catch {
return {}
}
}
function parseV4CollaborationReceiptStrict(result, required = false, expectedPhase = '', options = {}) {
const rawText = typeof result?.text === 'string' ? result.text : ''
if (!required) return { text: rawText, collaboration: null }
let text = rawText
let receipt = result?.collaboration
// Natural discussion turns are free-form answers; a slightly malformed
// receipt there must not fail the turn and end the discussion. Strict
// phased-V4 receipts keep failing closed.
const lenient = expectedPhase === 'discussion'
let trailingVisibleText = ''
let unreadableReceipt = false
const block = rawText.match(V4_RECEIPT_BLOCK) || rawText.match(V4_RECEIPT_MARKER)
if (block) {
const markerStart = Number(block.index) || 0
const suffix = rawText.slice(markerStart + block[0].length).trim()
// A closing code fence after the receipt is formatting, not content.
if (suffix && !isV4InternalProtocolCommentary(suffix) && !/^[\s`~]*$/.test(suffix)) {
if (!lenient) throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
trailingVisibleText = suffix
}
const encoded = String(block[1] || '').trim()
if (!encoded || Buffer.byteLength(encoded) > V4_RECEIPT_MAX_BYTES) {
if (!lenient) throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
unreadableReceipt = true
}
if (!unreadableReceipt) {
try {
let parsed
try {
parsed = JSON.parse(encoded)
} catch (error) {
if (!lenient) throw error
// Common hand-written slips: smart quotes and trailing commas.
parsed = JSON.parse(encoded
.replace(/[\u201c\u201d]/g, '"')
.replace(/[\u2018\u2019]/g, "'")
.replace(/,\s*([}\]])/g, '$1'))
}
if (receipt != null && canonicalJson(receipt) !== canonicalJson(parsed)) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_MISMATCH')
}
receipt = parsed
} catch (error) {
if (error?.message === 'LOCAL_RUN_COLLABORATION_RECEIPT_MISMATCH') throw error
if (!lenient) throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
unreadableReceipt = true
}
}
text = rawText.slice(0, markerStart) + (trailingVisibleText ? `\n\n${trailingVisibleText}` : '')
} else if (/\[\[MELDWORK_COLLABORATION/i.test(rawText)) {
if (!lenient) throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
unreadableReceipt = true
text = rawText.slice(0, rawText.search(/\[\[MELDWORK_COLLABORATION/i))
}
if (unreadableReceipt) receipt = null
if (lenient && receipt && typeof receipt === 'object' && !Array.isArray(receipt)) {
// Keep only discussion fields and fill a missing summary, so an extra key
// or a long summary does not discard the Agent's whole contribution.
const decisionReason = typeof receipt.taskDecision?.reason === 'string'
? receipt.taskDecision.reason
: ''
const summary = typeof receipt.summary === 'string' && receipt.summary.trim()
? receipt.summary.trim().slice(0, V4_RECEIPT_MAX_TEXT)
: (decisionReason.trim().slice(0, V4_RECEIPT_MAX_TEXT) || 'Discussion contribution')
receipt = {
version: 1,
phase: 'discussion',
summary,
...(receipt.taskDecision != null ? { taskDecision: receipt.taskDecision } : {}),
}
}
text = stripV4InternalProtocolTail(
text.replace(LEGACY_CONSENSUS_MARKER, '').replace(/\n{3,}/g, '\n\n'),
)
if (receipt == null && options.allowMissingV4Receipt === true && text) {
return { text, collaboration: null }
}
if (receipt == null && expectedPhase === 'proposal'
&& options.allowMissingProposalReceipt === true && text) {
receipt = {
version: 1,
phase: 'proposal',
summary: 'The Agent returned a visible proposal without a structured receipt.',
capabilities: ['Delivered a user-facing proposal'],
intendedWork: ['Addressed the current user task'],
deliverables: ['Visible Agent response'],
dependencies: [],
}
}
if (receipt == null) throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_REQUIRED')
if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
}
try {
if (Buffer.byteLength(canonicalJson(receipt)) > V4_RECEIPT_MAX_BYTES) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
}
} catch (error) {
if (error?.message === 'LOCAL_RUN_COLLABORATION_RECEIPT_INVALID') throw error
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
}
const phase = String(expectedPhase || '')
const summary = boundedReceiptText(receipt.summary)
const allowed = new Set([
'version', 'phase', 'summary',
...(phase === 'discussion' ? ['taskDecision'] : []),
...(['challenge', 'verification'].includes(phase) ? ['verdict'] : []),
...(phase === 'proposal'
? ['capabilities', 'intendedWork', 'deliverables', 'dependencies'] : []),
...(phase === 'challenge'
? [
'proposedAssignments', 'finalizerKind', 'verifierKinds',
'supportedPlanHash', 'agreeToPlan',
] : []),
...(phase === 'work' ? ['workItemId', 'deliverables'] : []),
...(phase === 'synthesis' ? ['resolvedIssueIds'] : []),
])
if (!V4_AGENT_PHASES.has(phase) || !summary
|| Object.keys(receipt).some(key => !allowed.has(key))
|| (receipt.version != null && receipt.version !== 1)
|| (receipt.phase != null && receipt.phase !== phase)) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
}
if (['challenge', 'verification'].includes(phase)
&& !['support', 'contradict'].includes(receipt.verdict)) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
}
const resolvedIssueIds = phase === 'synthesis'
? boundedReceiptList(receipt.resolvedIssueIds)
: null
if (phase === 'synthesis' && !Array.isArray(receipt.resolvedIssueIds)) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
}
if (phase === 'challenge' && receipt.supportedPlanHash != null
&& !SHA256.test(String(receipt.supportedPlanHash))) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
}
const proposalList = (field, requireItem = false) => {
if (!Array.isArray(receipt[field])) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
}
const list = boundedReceiptList(receipt[field])
if (requireItem && list.length === 0) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
}
return list
}
const boundedList = value => {
if (value == null) return []
if (!Array.isArray(value) || value.length > V4_RECEIPT_MAX_ITEMS) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
}
const result = value.map(item => boundedReceiptText(item))
if (result.some(item => !item)) throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
return result
}
const normalized = {
version: 1,
phase,
summary,
...(phase === 'discussion' && receipt.taskDecision != null
? leniencyTaskDecision(receipt.taskDecision, summary) : {}),
...(['challenge', 'verification'].includes(phase) ? { verdict: receipt.verdict } : {}),
...(phase === 'proposal' ? {
capabilities: proposalList('capabilities', true),
intendedWork: proposalList('intendedWork', true),
deliverables: proposalList('deliverables', true),
dependencies: proposalList('dependencies'),
} : {}),
...(phase === 'challenge' ? {
...(Array.isArray(receipt.proposedAssignments)
? {
proposedAssignments: receipt.proposedAssignments.map(assignment => (
assignment && typeof assignment === 'object' && !Array.isArray(assignment)
&& assignment.role === 'finalizer'
? { ...assignment, role: 'integrator' }
: assignment
)),
}
: {}),
...(receipt.finalizerKind ? { finalizerKind: boundedReceiptText(receipt.finalizerKind, 120) } : {}),
...(Array.isArray(receipt.verifierKinds) ? { verifierKinds: boundedList(receipt.verifierKinds) } : {}),
...(receipt.supportedPlanHash ? { supportedPlanHash: receipt.supportedPlanHash } : {}),
...(typeof receipt.agreeToPlan === 'boolean' ? { agreeToPlan: receipt.agreeToPlan } : {}),
} : {}),
...(phase === 'work' ? {
...(receipt.workItemId ? { workItemId: boundedReceiptText(receipt.workItemId, 120) } : {}),
deliverables: boundedList(receipt.deliverables),
} : {}),
...(phase === 'synthesis' ? { resolvedIssueIds } : {}),
}
return { text, collaboration: Object.freeze(JSON.parse(canonicalJson(normalized))) }
}
function parseV4CollaborationReceipt(result, required = false, expectedPhase = '', options = {}) {
try {
return parseV4CollaborationReceiptStrict(result, required, expectedPhase, options)
} catch (error) {
const code = String(error?.message || '')
if (expectedPhase !== 'proposal'
|| options.allowMissingProposalReceipt !== true
|| !['LOCAL_RUN_COLLABORATION_RECEIPT_INVALID', 'LOCAL_RUN_COLLABORATION_RECEIPT_REQUIRED']
.includes(code)) {
throw error
}
const rawText = typeof result?.text === 'string' ? result.text : ''
const markerIndex = rawText.search(/\[\[MELDWORK_COLLABORATION/i)
const visibleText = (markerIndex >= 0 ? rawText.slice(0, markerIndex) : rawText)
.replace(LEGACY_CONSENSUS_MARKER, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
if (!visibleText) throw error
return {
text: visibleText,
collaboration: Object.freeze({
version: 1,
phase: 'proposal',
summary: 'The Agent returned a visible proposal without a valid structured receipt.',
capabilities: ['Delivered a user-facing proposal'],
intendedWork: ['Addressed the current user task'],
deliverables: ['Visible Agent response'],
dependencies: [],
}),
}
}
}
function cleanReceiptText(value, limit) {
return String(value || '').trim().slice(0, limit)
}
module.exports = {
V4_AGENT_PHASES,
V4_RECEIPT_MARKER_PREFIX,
parseV4CollaborationReceipt,
}
@@ -533,6 +533,37 @@ function publicCollaborationText(value, limit = MAX_TEXT_CHARS) {
.slice(0, limit)
}
const PRIVATE_FILESYSTEM_ROOT = /^\/(?:Users|home|private|var|tmp|etc|opt|Volumes|root|usr|System|Library|Applications|mnt|srv)(?:\/|$)/
function peerPathText(candidate, workspaceRoot) {
const trailing = candidate.match(/[.,;:!?]+$/)?.[0] || ''
const clean = trailing ? candidate.slice(0, -trailing.length) : candidate
if (workspaceRoot && (clean === workspaceRoot || clean.startsWith(`${workspaceRoot}/`))) {
return `${clean === workspaceRoot ? '.' : clean.slice(workspaceRoot.length + 1)}${trailing}`
}
if (PRIVATE_FILESYSTEM_ROOT.test(clean)) return `[path]${trailing}`
// Routes and commands such as /api/users or /login are not local files.
return candidate
}
// Peer-visible text for natural discussion transcripts. Unlike
// publicCollaborationText, files inside the shared workspace stay readable as
// workspace-relative paths and non-filesystem tokens (API routes, slash
// commands) are kept; other absolute local paths and secrets stay redacted.
function peerCollaborationText(value, options = {}) {
const workspaceRoot = typeof options.workspaceRoot === 'string'
? options.workspaceRoot.replace(/\/+$/, '')
: ''
return redactSecrets(String(value || ''))
.replace(/\bfile:\/\/(\/[^\s"'`<>]+)/giu, (_match, filePath) => peerPathText(filePath, workspaceRoot))
.replace(/(^|[\s("'`])(\/(?!\/)[^\s"'`<>)]*)/gmu, (_match, lead, candidate) => (
`${lead}${peerPathText(candidate, workspaceRoot)}`
))
.replace(/\b[A-Za-z]:\\(?:[^\s"'`<>]+\\)*[^\s"'`<>]*/gu, '[path]')
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
.trim()
}
function collaborationPackageText(handoff, entries) {
const lines = [
'MELDWORK_COLLABORATION_PACKAGE_V1',
@@ -557,6 +588,7 @@ function collaborationPackageText(handoff, entries) {
}
module.exports = {
peerCollaborationText,
BLACKBOARD_ENTRY_VERSION,
COLLABORATION_STATE_VERSION,
HANDOFF_VERSION,
+55 -1
View File
@@ -31,4 +31,58 @@ function parseTaskDecision(input) {
}
}
module.exports = { parseTaskDecision }
const STATUS_SYNONYMS = new Map([
['completed', 'completed'], ['complete', 'completed'], ['done', 'completed'],
['finished', 'completed'], ['success', 'completed'], ['succeeded', 'completed'],
['continue', 'continue'], ['continuing', 'continue'], ['in-progress', 'continue'],
['in_progress', 'continue'], ['ongoing', 'continue'], ['pending', 'continue'],
['blocked', 'blocked'], ['blocker', 'blocked'],
['needs-human', 'needs-human'], ['needs_human', 'needs-human'],
['needs human', 'needs-human'], ['needshuman', 'needs-human'], ['human', 'needs-human'],
])
function agentKindToken(value) {
return typeof value === 'string'
? value.trim().replace(/^@+/, '').toLowerCase()
: ''
}
// Agents write task decisions by hand at the end of free text. Small slips
// (capitalised status, "done", an @handle, a single string instead of a list,
// a completed answer without a separate file) used to fail the whole turn
// and silently end the discussion. Normalise them first; the strict parser
// still validates the result.
function normalizeTaskDecision(input, options = {}) {
if (!input || typeof input !== 'object' || Array.isArray(input)) return input
const status = STATUS_SYNONYMS.get(String(input.status || '').trim().toLowerCase())
|| String(input.status || '')
const textList = value => (Array.isArray(value) ? value : (value == null ? [] : [value]))
.filter(item => typeof item === 'string' && item.trim())
.map(item => item.trim().slice(0, 800))
.slice(0, 16)
const reason = typeof input.reason === 'string' && input.reason.trim()
? input.reason.trim().slice(0, 1600)
: String(options.fallbackReason || '').trim().slice(0, 1600) || 'No reason given.'
let deliverables = textList(input.deliverables)
// A completed answer is itself the deliverable when nothing else was named.
if (status === 'completed' && !deliverables.length) deliverables = ['The answer in this message']
const nextKinds = [...new Set(textList(input.nextKinds).map(agentKindToken).filter(Boolean))]
const handoffTo = agentKindToken(input.handoffTo)
return {
status,
reason,
deliverables,
...(status === 'continue' && handoffTo ? { handoffTo } : {}),
// Keep an explicit list (even an empty one) so the stored decision says
// what the Agent wrote; peers can only be requested while continuing.
...(input.nextKinds != null
? { nextKinds: status === 'continue' ? nextKinds.slice(0, 32) : [] }
: {}),
}
}
function parseLenientTaskDecision(input, options = {}) {
return parseTaskDecision(normalizeTaskDecision(input, options))
}
module.exports = { normalizeTaskDecision, parseLenientTaskDecision, parseTaskDecision }
+45 -44
View File
@@ -1,6 +1,8 @@
const electron = require('electron')
const { app, BrowserWindow, dialog, ipcMain, Menu, Notification, protocol, shell } = electron
const fs = require('node:fs')
const path = require('node:path')
const { redactSecrets } = require('./security/secret-redaction.cjs')
const { AttachmentStore } = require('./attachments/attachment-store.cjs')
const { ContentBlobStore } = require('./attachments/content-blob-store.cjs')
const { ContextPackStore } = require('./collaboration/context-pack-store.cjs')
@@ -69,26 +71,37 @@ const {
resolveKnowledgeBaseSources,
} = require('./knowledge/local-knowledge-base.cjs')
const { LocalKnowledgeConnectors } = require('./knowledge/local-knowledge-connectors.cjs')
const { ProviderStore } = require('./providers/provider-store.cjs')
const { nativeOpenClawOptions } = require('./agents/cli/openclaw-runtime.cjs')
const { migrateLegacyProductData } = require('./product-data-migration.cjs')
const {
EXTERNAL_PROVIDER_KINDS,
providerAgentKind,
providerOptionsFor,
} = require('./providers/provider-options.cjs')
const LOCAL_AGENT_KINDS = new Set([
'codex', 'hermes', 'openclaw', 'workbuddy', 'pi', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen',
'opencodereview',
])
const QUIT_CLEANUP_TIMEOUT_MS = 2500
let mainWindow = null
// Faults outside a request handler are written to a small private log in
// userData (secrets redacted, size-capped) instead of disappearing or
// taking the app down, so instability reports have evidence.
function recordMainProcessFault(kind, detail) {
const text = String(detail?.stack || detail?.message || detail || '').slice(0, 4000)
console.error(`[meldwork] ${kind}`, text)
try {
const file = path.join(app.getPath('userData'), 'meldwork-diagnostics.log')
try {
if (fs.statSync(file).size > 1024 * 1024) fs.renameSync(file, `${file}.1`)
} catch { /* first entry */ }
fs.appendFileSync(file, `${new Date().toISOString()} ${kind} ${redactSecrets(text)}\n`, { mode: 0o600 })
} catch { /* diagnostics must never throw */ }
}
process.on('unhandledRejection', reason => recordMainProcessFault('unhandledRejection', reason))
process.on('uncaughtException', error => recordMainProcessFault('uncaughtException', error))
let workspace = null
let workspaceChangedListener = null
let workspaceRunFinishedListener = null
let workspaceRunEventListener = null
let installer = null
let localAgentRefreshQueue = Promise.resolve()
let providerStore = null
let customAgentStore = null
let agentConnectors = null
let cloudAgentRuntime = null
@@ -225,18 +238,6 @@ function openExternalUrl(value) {
return true
}
function providerOptions(kind, context = {}) {
if (!EXTERNAL_PROVIDER_KINDS.has(kind)) return {}
const status = providerStore?.status(kind) || {}
if (!status.configured && !(kind === 'openclaw' && context.nativeRuntime)) return {}
return providerOptionsFor(
kind,
status.configured ? providerStore.envForAgent(kind) : {},
context,
status,
)
}
async function nativeShellEnvironment(options = {}) {
const home = app.getPath('home')
const resolved = await resolveNativeShellEnvironment({ home, refresh: options.refresh === true })
@@ -341,33 +342,30 @@ async function runCoreAgent(agent, prompt, workdir, options = {}) {
const connectorCredentialIsolation = options.connectorCredentialIsolation === true
const runOptions = { ...options }
delete runOptions.connectorCredentialIsolation
const status = providerStore.status(agent.kind)
if (!connectorCredentialIsolation && status.error) {
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
}
const shellEnvironment = connectorCredentialIsolation ? null : await nativeShellEnvironment()
const nativeRuntime = !connectorCredentialIsolation
&& agent.kind === 'openclaw' && !status.configured
// OpenClaw runs through its isolated native runtime scope; every other Agent
// CLI uses its own native credentials.
const nativeRuntime = !connectorCredentialIsolation && agent.kind === 'openclaw'
? await resolveNativeOpenClawRuntime({
executable: agent.executable,
env: shellEnvironment.env,
home: shellEnvironment.home,
})
: null
const injected = connectorCredentialIsolation
const injected = connectorCredentialIsolation || agent.kind !== 'openclaw' || !nativeRuntime
? {}
: providerOptions(agent.kind, {
...runOptions,
workdir,
: nativeOpenClawOptions({
storageRoot: app.getPath('userData'),
nativeRuntime,
workdir,
sessionRef: runOptions.sessionRef,
allowWrite: runOptions.sandbox === 'workspace-write',
runtime: nativeRuntime,
openclawVersion: agent.version,
})
const nativeEnv = connectorCredentialIsolation || agent.kind === 'openclaw'
? {}
: {
...nativeCredentialEnvironment(agent.kind, shellEnvironment.env, {
providerConfigured: status.configured,
}),
...nativeCredentialEnvironment(agent.kind, shellEnvironment.env),
...(shellEnvironment.env.PATH ? { PATH: shellEnvironment.env.PATH } : {}),
}
const callerEnv = connectorCredentialIsolation || agent.kind !== 'openclaw'
@@ -542,9 +540,6 @@ function createWorkspace() {
credentialState: async (kind, agent) => {
if (customAgentStore.has(kind)) return { state: 'ready', source: 'custom-agent' }
if (agentConnectors.has(kind)) return { state: 'ready', source: 'agent-connector' }
if (providerStore.status(kind).error) {
return { state: 'unknown', source: 'provider-credential-unavailable' }
}
const shellEnvironment = await nativeShellEnvironment()
return resolveNativeCredentialState(kind, {
executable: agent?.executable,
@@ -554,7 +549,6 @@ function createWorkspace() {
})
},
agentLabel: kind => customAgentStore.label(kind) || agentConnectors.label(kind),
sharedProviderReady: kind => EXTERNAL_PROVIDER_KINDS.has(kind) && providerStore.status(kind).configured,
connectorRuntime: agentConnectors,
naturalAgentResponses: true,
runAgent: async (agent, prompt, workdir, options = {}) => {
@@ -702,6 +696,14 @@ function createWindow() {
showOfflinePage(errorCode)
})
mainWindow.webContents.on('did-finish-load', flushPendingOpenGroup)
// A crashed renderer used to leave a blank window; reload it so the
// workspace (whose state lives in the main process) comes back.
mainWindow.webContents.on('render-process-gone', (_event, details) => {
recordMainProcessFault('render-process-gone', details?.reason || 'unknown')
if (details?.reason !== 'clean-exit' && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.reload()
}
})
mainWindow.on('closed', () => {
mainWindow = null
})
@@ -751,8 +753,6 @@ function registerIpc() {
openExternalUrl,
getOutcomeStore: () => workspace?.outcomeStore,
getSkillTrustStore: () => skillTrustStore,
providerStore,
providerAgentKind,
refreshLocalAgentState,
registerTrustedHandle,
skillCatalog,
@@ -785,11 +785,12 @@ if (!hasSingleInstanceLock) {
storagePath: path.join(app.getPath('userData'), 'meldwork-cloud-agent-bridges.json'),
fetch: globalThis.fetch,
})
providerStore = new ProviderStore({
storagePath: path.join(app.getPath('userData'), 'meldwork-provider.json'),
safeStorage: lazySafeStorage,
allowedKinds: [...LOCAL_AGENT_KINDS],
})
// The shared Provider store was removed; drop any legacy encrypted
// credentials so they do not linger on disk unread.
try {
const { rmSync } = require('node:fs')
rmSync(path.join(app.getPath('userData'), 'meldwork-provider.json'), { force: true })
} catch { /* best-effort cleanup */ }
knowledgeBaseStore = new KnowledgeBaseStore({
storagePath: path.join(app.getPath('userData'), 'meldwork-knowledge-base.json'),
})
-7
View File
@@ -176,13 +176,6 @@ if (isLocalDocument) Object.assign(desktopApi, {
open: id => ipcRenderer.invoke('local-attachments:open', id),
discard: ids => ipcRenderer.invoke('local-attachments:discard', ids),
}),
localAgentProvider: Object.freeze({
status: kind => ipcRenderer.invoke('local-agent-provider:status', kind),
probe: kind => ipcRenderer.invoke('local-agent-provider:probe', kind),
save: (kind, input) => ipcRenderer.invoke('local-agent-provider:save', kind, input),
activate: (kind, preset) => ipcRenderer.invoke('local-agent-provider:activate', kind, preset),
delete: (kind, preset) => ipcRenderer.invoke('local-agent-provider:delete', kind, preset),
}),
localKnowledgeBase: Object.freeze({
status: kind => (kind ? ipcRenderer.invoke('local-knowledge-base:status', kind) : ipcRenderer.invoke('local-knowledge-base:status')),
openGuide: (kind, action) => ipcRenderer.invoke('local-knowledge-base:open-guide', kind, action),
-1
View File
@@ -7,7 +7,6 @@ const MIGRATIONS = Object.freeze([
['workspace.json', 'workspace.json'],
['run-ledger.json', 'run-ledger.json'],
['custom-agents.json', 'custom-agents.json'],
['provider.json', 'provider.json'],
['knowledge-base.json', 'knowledge-base.json'],
['private', 'private'],
])
-184
View File
@@ -1,184 +0,0 @@
const { managedOpenClawOptions, nativeOpenClawOptions } = require('../agents/cli/openclaw-runtime.cjs')
const EXTERNAL_PROVIDER_KINDS = new Set([
'codex', 'hermes', 'openclaw', 'workbuddy', 'pi', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen',
'opencodereview',
])
const OFFICIAL_PROVIDER_BASE_URLS = Object.freeze({
codex: 'https://api.openai.com/v1',
hermes: 'https://api.openai.com/v1',
openclaw: 'https://api.openai.com/v1',
workbuddy: '',
pi: '',
kimi: 'https://api.moonshot.cn/v1',
mimo: '',
claude: 'https://api.anthropic.com',
gemini: 'https://generativelanguage.googleapis.com/v1beta',
opencode: '',
qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
opencodereview: 'https://api.openai.com/v1',
})
function chatCompletionsUrl(baseUrl) {
const normalized = String(baseUrl || '').trim().replace(/\/+$/, '')
if (!normalized) return ''
return /\/chat\/completions$/i.test(normalized)
? normalized
: `${normalized}/chat/completions`
}
function providerPresetFromStatus(kind, status = {}) {
if (['official', 'openrouter', 'custom'].includes(status.activePreset)) {
return status.activePreset
}
const provider = String(status.provider || '').trim().toLowerCase()
const baseUrl = String(status.baseUrl || '').trim().replace(/\/+$/, '')
if (/openrouter/i.test(provider) || /openrouter\.ai$/i.test(new URL(baseUrl || 'https://example.com').hostname)) {
return 'openrouter'
}
if (OFFICIAL_PROVIDER_BASE_URLS[kind]
&& baseUrl
&& baseUrl === OFFICIAL_PROVIDER_BASE_URLS[kind]) {
return 'official'
}
return 'custom'
}
function providerOptionsFor(kind, generic, context = {}, status = {}) {
const preset = providerPresetFromStatus(kind, status)
if (kind === 'codex') return { env: generic }
if (kind === 'hermes') {
return {
provider: { id: 'openai-api', model: generic.OPENAI_MODEL },
env: {
...generic,
HERMES_INFERENCE_PROVIDER: 'openai-api',
HERMES_INFERENCE_MODEL: generic.OPENAI_MODEL,
},
}
}
if (kind === 'workbuddy') {
return {
env: {
...generic,
CODEBUDDY_MODEL: generic.OPENAI_MODEL,
CODEBUDDY_API_KEY: generic.OPENAI_API_KEY,
CODEBUDDY_BASE_URL: generic.OPENAI_BASE_URL,
},
}
}
if (kind === 'pi') {
return {
env: {
...generic,
PI_MODEL: generic.OPENAI_MODEL,
PI_API_KEY: generic.OPENAI_API_KEY,
PI_BASE_URL: generic.OPENAI_BASE_URL,
},
}
}
if (kind === 'kimi') {
return {
env: {
...generic,
MOONSHOT_API_KEY: generic.OPENAI_API_KEY,
KIMI_API_KEY: generic.OPENAI_API_KEY,
KIMI_MODEL_API_KEY: generic.OPENAI_API_KEY,
KIMI_MODEL_BASE_URL: generic.OPENAI_BASE_URL,
KIMI_MODEL_NAME: generic.OPENAI_MODEL,
},
}
}
if (kind === 'mimo') {
return {
env: {
...generic,
MIMO_API_KEY: generic.OPENAI_API_KEY,
MIMO_BASE_URL: generic.OPENAI_BASE_URL,
MIMO_MODEL: generic.OPENAI_MODEL,
},
}
}
if (kind === 'claude') {
return {
provider: { id: 'anthropic', model: generic.OPENAI_MODEL, baseUrl: generic.OPENAI_BASE_URL },
env: {
...generic,
ANTHROPIC_API_KEY: generic.OPENAI_API_KEY,
MELDWORK_PROVIDER_API_KEY: generic.OPENAI_API_KEY,
ANTHROPIC_BASE_URL: generic.OPENAI_BASE_URL,
ANTHROPIC_MODEL: generic.OPENAI_MODEL,
},
}
}
if (kind === 'gemini') {
return {
env: {
...generic,
GEMINI_API_KEY: generic.OPENAI_API_KEY,
GOOGLE_API_KEY: generic.OPENAI_API_KEY,
GEMINI_MODEL: generic.OPENAI_MODEL,
},
}
}
if (kind === 'qwen') {
return {
provider: { id: 'openai', model: generic.OPENAI_MODEL },
env: {
...generic,
DASHSCOPE_API_KEY: generic.OPENAI_API_KEY,
},
}
}
if (kind === 'opencode') {
return {
env: {
...generic,
...(preset === 'openrouter' ? { OPENROUTER_API_KEY: generic.OPENAI_API_KEY } : {}),
},
}
}
if (kind === 'opencodereview') {
return {
env: {
...generic,
OCR_LLM_URL: chatCompletionsUrl(generic.OPENAI_BASE_URL),
OCR_LLM_TOKEN: generic.OPENAI_API_KEY,
OCR_LLM_MODEL: generic.OPENAI_MODEL,
OCR_USE_ANTHROPIC: 'false',
},
}
}
if (kind === 'openclaw' && context.storageRoot && context.workdir) {
if (!generic?.OPENAI_API_KEY) {
return nativeOpenClawOptions({
storageRoot: context.storageRoot,
workdir: context.workdir,
sessionRef: context.sessionRef,
allowWrite: context.sandbox === 'workspace-write',
runtime: context.nativeRuntime,
})
}
return managedOpenClawOptions({
storageRoot: context.storageRoot,
workdir: context.workdir,
sessionRef: context.sessionRef,
allowWrite: context.sandbox === 'workspace-write',
provider: generic,
})
}
return {}
}
function providerAgentKind(value) {
const kind = String(value || '').trim()
if (!EXTERNAL_PROVIDER_KINDS.has(kind)) throw new Error('PROVIDER_AGENT_UNSUPPORTED')
return kind
}
module.exports = {
EXTERNAL_PROVIDER_KINDS,
providerAgentKind,
providerOptionsFor,
}
-382
View File
@@ -1,382 +0,0 @@
const fs = require('node:fs')
const { atomicWritePrivateFile } = require('../security/private-file.cjs')
const STORE_VERSION = 3
const SINGLE_PROFILE_STORE_VERSION = 2
const LEGACY_STORE_VERSION = 1
const PROVIDER_KIND = /^[a-z0-9][a-z0-9-]{0,31}$/
const PROVIDER_PRESETS = new Set(['official', 'openrouter', 'custom'])
const EMPTY_METADATA = Object.freeze({ provider: '', baseUrl: '', model: '' })
const OPENROUTER_PROVIDER = Object.freeze({
provider: 'OpenRouter',
baseUrl: 'https://openrouter.ai/api/v1',
})
const OFFICIAL_PROVIDERS = Object.freeze({
codex: { provider: 'OpenAI', baseUrl: 'https://api.openai.com/v1' },
hermes: { provider: 'OpenAI API', baseUrl: 'https://api.openai.com/v1' },
openclaw: { provider: 'OpenAI API', baseUrl: 'https://api.openai.com/v1' },
workbuddy: { provider: 'WorkBuddy Official', baseUrl: '' },
pi: { provider: 'Pi Native', baseUrl: '' },
kimi: { provider: 'Moonshot AI', baseUrl: 'https://api.moonshot.cn/v1' },
mimo: { provider: 'MiMo Native', baseUrl: '' },
claude: { provider: 'Anthropic', baseUrl: 'https://api.anthropic.com' },
gemini: { provider: 'Google AI Studio', baseUrl: 'https://generativelanguage.googleapis.com/v1beta' },
opencode: { provider: 'OpenCode Provider', baseUrl: '' },
qwen: { provider: 'DashScope', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1' },
opencodereview: { provider: 'OpenAI API', baseUrl: 'https://api.openai.com/v1' },
})
function normalizeMetadata(input) {
const provider = String(input?.provider || '').trim()
let baseUrl = String(input?.baseUrl || '').trim().replace(/\/+$/, '')
baseUrl = baseUrl.replace(/\/chat\/completions$/i, '')
const model = String(input?.model || '').trim()
if (!provider || provider.length > 80 || !baseUrl || baseUrl.length > 300
|| !model || model.length > 160) {
throw new Error('PROVIDER_INVALID_METADATA')
}
let parsed
try { parsed = new URL(baseUrl) } catch { throw new Error('PROVIDER_INVALID_METADATA') }
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname)
if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback)) {
throw new Error('PROVIDER_INSECURE_BASE_URL')
}
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
throw new Error('PROVIDER_INVALID_METADATA')
}
return { provider, baseUrl, model }
}
function sortedKeys(value) {
return value && typeof value === 'object' && !Array.isArray(value)
? Reflect.ownKeys(value).sort().join(',')
: ''
}
function inferStoredPreset(entry) {
const provider = String(entry?.provider || '').toLowerCase()
const baseUrl = String(entry?.baseUrl || '')
return /openrouter/i.test(provider) || /(^|\.)openrouter\.ai$/i.test(hostname(baseUrl))
? 'openrouter'
: 'custom'
}
function hostname(value) {
try { return new URL(value).hostname } catch { return '' }
}
function validatePresetMetadata(kind, preset, metadata) {
if (preset === 'custom') return metadata
const expected = preset === 'openrouter' ? OPENROUTER_PROVIDER : OFFICIAL_PROVIDERS[kind]
if (!expected?.baseUrl
|| metadata.provider !== expected.provider
|| metadata.baseUrl !== expected.baseUrl) {
throw new Error('PROVIDER_INVALID_METADATA')
}
return metadata
}
class ProviderStore {
constructor({ storagePath, safeStorage, allowedKinds = [] }) {
if (typeof storagePath !== 'string' || !storagePath) {
throw new Error('PROVIDER_STORAGE_PATH_REQUIRED')
}
const normalizedKinds = [...new Set(allowedKinds.map(kind => String(kind || '').trim()))]
if (!normalizedKinds.length || normalizedKinds.some(kind => !PROVIDER_KIND.test(kind))) {
throw new Error('PROVIDER_AGENT_KINDS_REQUIRED')
}
this.storagePath = storagePath
this.safeStorage = safeStorage
this.allowedKinds = new Set(normalizedKinds)
}
encryptionAvailable() {
try {
return Boolean(this.safeStorage?.isEncryptionAvailable?.())
} catch {
return false
}
}
status(kind, { probeEncryption = false } = {}) {
const targetKind = this.normalizeKind(kind)
const credentialStored = fs.existsSync(this.storagePath)
if (!credentialStored && !probeEncryption) {
return {
provider: '',
baseUrl: '',
model: '',
activePreset: 'official',
profiles: {},
encryptionAvailable: null,
configured: false,
}
}
const encryptionAvailable = this.encryptionAvailable()
let metadata = EMPTY_METADATA
let configured = false
let credentialUnavailable = false
let activePreset = 'official'
const profiles = {}
if (credentialStored) {
try {
const agent = this.readDocument().agents[targetKind]
if (agent) {
activePreset = agent.activePreset
for (const [preset, entry] of Object.entries(agent.profiles)) {
profiles[preset] = {
provider: entry.provider,
baseUrl: entry.baseUrl,
model: entry.model,
configured: false,
}
try {
this.decryptEntry(entry)
profiles[preset].configured = true
} catch { /* retain saved metadata while the credential is unavailable */ }
}
const activeEntry = profiles[activePreset]
if (activeEntry) {
metadata = activeEntry
configured = activeEntry.configured
credentialUnavailable = !configured
}
}
} catch { credentialUnavailable = true }
}
return {
provider: metadata.provider,
baseUrl: metadata.baseUrl,
model: metadata.model,
activePreset,
profiles,
encryptionAvailable,
configured,
...(credentialUnavailable ? { error: true } : {}),
}
}
save(kind, input) {
const targetKind = this.normalizeKind(kind)
if (sortedKeys(input) !== 'apiKey,baseUrl,model,preset,provider'
|| typeof input.apiKey !== 'string' || !input.apiKey.trim()
|| input.apiKey.trim().length > 8192) {
throw new Error('PROVIDER_INVALID_CREDENTIAL')
}
this.requireEncryption()
const apiKey = input.apiKey.trim()
const preset = this.normalizePreset(input.preset)
const metadata = validatePresetMetadata(targetKind, preset, normalizeMetadata(input))
let encrypted
try {
encrypted = this.safeStorage.encryptString(apiKey)
if (!Buffer.isBuffer(encrypted) || encrypted.length === 0
|| this.safeStorage.decryptString(encrypted) !== apiKey) {
throw new Error('invalid encryption result')
}
} catch {
throw new Error('PROVIDER_ENCRYPTION_FAILED')
}
const document = fs.existsSync(this.storagePath)
? this.readDocument()
: { version: STORE_VERSION, agents: {} }
const agent = document.agents[targetKind] || { activePreset: 'official', profiles: {} }
agent.profiles[preset] = {
...metadata,
encrypted: encrypted.toString('base64'),
}
agent.activePreset = preset
document.agents[targetKind] = agent
atomicWritePrivateFile(this.storagePath, JSON.stringify(document))
return this.status(targetKind)
}
activate(kind, preset) {
const targetKind = this.normalizeKind(kind)
const targetPreset = this.normalizePreset(preset)
if (!fs.existsSync(this.storagePath)) {
if (targetPreset !== 'official') throw new Error('PROVIDER_PROFILE_UNAVAILABLE')
return this.status(targetKind, { probeEncryption: true })
}
this.requireEncryption()
const document = this.readDocument()
const agent = document.agents[targetKind] || { activePreset: 'official', profiles: {} }
if (targetPreset !== 'official' && !agent.profiles[targetPreset]) {
throw new Error('PROVIDER_PROFILE_UNAVAILABLE')
}
if (agent.profiles[targetPreset]) this.decryptEntry(agent.profiles[targetPreset])
agent.activePreset = targetPreset
document.agents[targetKind] = agent
atomicWritePrivateFile(this.storagePath, JSON.stringify(document))
return this.status(targetKind)
}
delete(kind, preset = '') {
const targetKind = this.normalizeKind(kind)
if (fs.existsSync(this.storagePath)) {
const document = this.readDocument()
if (preset) {
const targetPreset = this.normalizePreset(preset)
const agent = document.agents[targetKind]
if (agent) {
delete agent.profiles[targetPreset]
if (agent.activePreset === targetPreset) agent.activePreset = 'official'
if (Object.keys(agent.profiles).length) document.agents[targetKind] = agent
else delete document.agents[targetKind]
}
} else {
delete document.agents[targetKind]
}
if (Object.keys(document.agents).length) {
atomicWritePrivateFile(this.storagePath, JSON.stringify(document))
} else {
fs.unlinkSync(this.storagePath)
}
}
return this.status(targetKind, { probeEncryption: true })
}
envForAgent(kind) {
const targetKind = this.normalizeKind(kind)
this.requireEncryption()
const agent = this.readDocument().agents[targetKind]
const entry = agent?.profiles?.[agent.activePreset]
if (!entry) throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
return {
OPENAI_API_KEY: this.decryptEntry(entry),
OPENAI_BASE_URL: entry.baseUrl,
OPENAI_MODEL: entry.model,
}
}
requireEncryption() {
if (!this.encryptionAvailable()) throw new Error('PROVIDER_ENCRYPTION_UNAVAILABLE')
}
normalizeKind(kind) {
const normalized = String(kind || '').trim()
if (!PROVIDER_KIND.test(normalized) || !this.allowedKinds.has(normalized)) {
throw new Error('PROVIDER_AGENT_UNSUPPORTED')
}
return normalized
}
normalizePreset(preset) {
const normalized = String(preset || '').trim().toLowerCase()
if (!PROVIDER_PRESETS.has(normalized)) throw new Error('PROVIDER_PRESET_UNSUPPORTED')
return normalized
}
decryptEntry(entry) {
this.requireEncryption()
try {
const encrypted = Buffer.from(entry.encrypted, 'base64')
if (!encrypted.length || encrypted.toString('base64') !== entry.encrypted) {
throw new Error('invalid ciphertext')
}
const apiKey = this.safeStorage.decryptString(encrypted)
if (typeof apiKey !== 'string' || !apiKey.trim()) throw new Error('invalid credential')
return apiKey
} catch {
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
}
}
readDocument() {
let payload
try {
payload = JSON.parse(fs.readFileSync(this.storagePath, 'utf8'))
} catch {
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
}
if (payload?.version === LEGACY_STORE_VERSION) {
if (sortedKeys(payload) !== 'baseUrl,encrypted,model,provider,version'
|| typeof payload.encrypted !== 'string') {
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
}
const rawEntry = {
provider: payload.provider,
baseUrl: payload.baseUrl,
model: payload.model,
encrypted: payload.encrypted,
}
return {
version: STORE_VERSION,
agents: Object.fromEntries([...this.allowedKinds].map((kind) => {
const entry = this.normalizeEntry(rawEntry)
const preset = inferStoredPreset(entry)
return [kind, {
activePreset: preset,
profiles: { [preset]: { ...entry } },
}]
})),
}
}
if (payload?.version === SINGLE_PROFILE_STORE_VERSION) {
if (sortedKeys(payload) !== 'agents,version'
|| !payload.agents || typeof payload.agents !== 'object'
|| Array.isArray(payload.agents)) {
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
}
const agents = {}
for (const [kind, rawEntry] of Object.entries(payload.agents)) {
if (!this.allowedKinds.has(kind)) throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
const entry = this.normalizeEntry(rawEntry)
const preset = inferStoredPreset(entry)
agents[kind] = { activePreset: preset, profiles: { [preset]: entry } }
}
return { version: STORE_VERSION, agents }
}
if (sortedKeys(payload) !== 'agents,version'
|| payload.version !== STORE_VERSION
|| !payload.agents || typeof payload.agents !== 'object'
|| Array.isArray(payload.agents)) {
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
}
const agents = {}
for (const [kind, entry] of Object.entries(payload.agents)) {
if (!this.allowedKinds.has(kind)) throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
agents[kind] = this.normalizeAgentEntry(entry)
}
return { version: STORE_VERSION, agents }
}
normalizeAgentEntry(entry) {
try {
if (sortedKeys(entry) !== 'activePreset,profiles'
|| !entry.profiles || typeof entry.profiles !== 'object'
|| Array.isArray(entry.profiles)) {
throw new Error('invalid agent entry')
}
const activePreset = this.normalizePreset(entry.activePreset)
const profiles = {}
for (const [preset, profile] of Object.entries(entry.profiles)) {
const normalizedPreset = this.normalizePreset(preset)
profiles[normalizedPreset] = this.normalizeEntry(profile)
}
if (activePreset !== 'official' && !profiles[activePreset]) {
throw new Error('missing active profile')
}
return { activePreset, profiles }
} catch {
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
}
}
normalizeEntry(entry) {
if (sortedKeys(entry) !== 'baseUrl,encrypted,model,provider'
|| typeof entry.encrypted !== 'string') {
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
}
const encrypted = Buffer.from(entry.encrypted, 'base64')
if (!encrypted.length || encrypted.toString('base64') !== entry.encrypted) {
throw new Error('PROVIDER_CREDENTIAL_UNAVAILABLE')
}
return { ...normalizeMetadata(entry), encrypted: entry.encrypted }
}
}
module.exports = { ProviderStore }
+51 -7
View File
@@ -1,3 +1,11 @@
const {
authFailureCertainty,
isNetworkFailure,
isProviderUnavailable,
isRateLimited,
retryAfterFromText,
} = require('./failure-signals.cjs')
const FAILURE_CATEGORIES = Object.freeze([
'authentication',
'compatibility',
@@ -121,23 +129,47 @@ function retryAfterMs(error, now) {
const parsed = parseRetryAfter(headerValue(headers, 'retry-after'), now)
if (parsed != null) return parsed
}
return null
// Local CLIs surface provider throttling as text ("retry after 20s").
return retryAfterFromText(error?.diagnostic || error?.message)
}
// A local CLI process failure carries a code-derived category (usually
// protocol). Its diagnostic often proves a transient provider condition, so
// refine it before deciding whether to retry and how long to wait.
function transientCategoryFromText(detail) {
if (!detail) return ''
if (isRateLimited(detail)) return 'rate_limit'
if (isNetworkFailure(detail)) return 'network'
if (isProviderUnavailable(detail)) return 'provider'
return ''
}
function classifiedCategory(error, code, status) {
const explicit = String(error?.failure?.category || '')
if (FAILURE_CATEGORY_SET.has(explicit)) return explicit
const detail = String(error?.diagnostic || error?.message || '')
if (FAILURE_CATEGORY_SET.has(explicit)) {
if (['protocol', 'execution'].includes(explicit)) {
const refined = transientCategoryFromText(error?.diagnostic || '')
if (refined) return refined
}
return explicit
}
if (code === 'LOCAL_BUDGET_EXHAUSTED') return 'budget'
if (/PERMISSION/.test(code)) return 'permission'
if (code === 'LOCAL_AGENT_EXECUTION_STOPPED' || /CANCEL|ABORT/.test(code)) {
return 'cancellation'
}
if (code === 'LOCAL_AGENT_SESSION_INVALID') return 'session'
if (status === 401 || status === 403 || code === 'LOCAL_AGENT_AUTH_REQUIRED'
|| /unauthori[sz]ed|forbidden|invalid token/i.test(detail)) return 'authentication'
// Only explicit sign-in statements prove that credentials are gone. A 403,
// a proxy refusal, or "unauthorized" inside unrelated output is retried as
// a provider-side refusal instead of stopping the Agent for the run.
const authCertainty = authFailureCertainty(detail)
if (status === 401 || code === 'LOCAL_AGENT_AUTH_REQUIRED'
|| authCertainty === 'definitive') return 'authentication'
if (status === 403 || code === 'LOCAL_AGENT_ACCESS_DENIED'
|| authCertainty === 'ambiguous') return 'provider'
if (status === 429 || /RATE_LIMIT|TOO_MANY_REQUESTS/.test(code)
|| /rate limit|too many requests/i.test(detail)) return 'rate_limit'
|| isRateLimited(detail)) return 'rate_limit'
if (NETWORK_CODES.has(code) || /NETWORK|CONNECTION/.test(code)
|| /socket hang up|temporary failure|network error/i.test(detail)) return 'network'
if (status != null && status >= 500) return 'provider'
@@ -244,18 +276,30 @@ function retryDecision(input, options = {}) {
if (safety?.sideEffectsPossible === true && safety.idempotencyMode !== 'durable') {
return Object.freeze({ ...base, action: 'human_gate', exhausted: false })
}
const maxDelayMs = Math.max(1, boundedInteger(
const configuredMaxDelayMs = Math.max(1, boundedInteger(
options.maxDelayMs, 10 * 1000, 24 * 60 * 60 * 1000,
))
// Provider throttling needs a longer and server-directed wait than a crashed
// process. Honor Retry-After up to the rate-limit cap instead of hammering
// the provider with the generic sub-second backoff.
const rateLimited = failure.category === 'rate_limit'
const maxDelayMs = rateLimited
? Math.max(configuredMaxDelayMs, boundedInteger(
options.rateLimitMaxDelayMs, 60 * 1000, 24 * 60 * 60 * 1000,
))
: configuredMaxDelayMs
const backoff = boundedBackoffDelay(attempt, { ...options, maxDelayMs })
const requestedDelay = failure.retryAfterMs == null
? 0
: Math.min(maxDelayMs, boundedInteger(failure.retryAfterMs, 0, maxDelayMs))
const minimumDelay = rateLimited && failure.retryAfterMs == null
? Math.min(maxDelayMs, boundedInteger(options.rateLimitMinDelayMs, 0, maxDelayMs))
: 0
return Object.freeze({
...base,
action: 'retry',
exhausted: false,
delayMs: Math.max(backoff, requestedDelay),
delayMs: Math.max(backoff, requestedDelay, minimumDelay),
})
}
+129
View File
@@ -0,0 +1,129 @@
// Pure text classifiers for local Agent failure diagnostics.
//
// Agent CLIs report failures as free text on stderr or inside structured error
// results. Classification decides whether Meldwork retries, asks the user to
// sign in again, or rebuilds a native session, so every rule here errs on the
// side of "ambiguous": only explicit login and API-key statements count as a
// definitive authentication failure. A bare 403, a proxy refusal, or a word
// such as "unauthorized" inside unrelated output must never mark an installed
// Agent as signed out.
const MAX_SIGNAL_TEXT_CHARS = 64 * 1024
const LOG_PREFIX = /^(?:\[[^\]]{1,40}\]\s*|(?:error|fatal|err|warning)\s*[:!-]\s*|[✗✘×!]\s*)+/i
const DEFINITIVE_AUTH_LINE = [
/^(?:invalid|expired|missing|incorrect|revoked)\s+(?:api[ _-]?key|(?:access|refresh|auth|oauth|bearer)[ _-]?token|credentials?)\b/i,
/^(?:authentication|authorization)\s+(?:failed|required)\b/i,
/^(?:please\s+)?(?:log|sign)\s+in\b/i,
/^(?:please\s+)?run\s+\S*\s*(?:\/)?login\b/i,
/^not\s+(?:logged|signed)\s+in\b/i,
/^you\s+are\s+not\s+(?:logged|signed)\s+in\b/i,
/^select an auth type\b/i,
/^oauth\s+token\s+(?:has\s+)?(?:expired|is\s+invalid|was\s+revoked)\b/i,
/^HTTP\s+401\s*:/i,
/^provider rejected\b/i,
/^(?:认证|鉴权|登录)(?:失败|过期)/,
/^(?:令牌|凭据|密钥)(?:无效|缺失|已过期)/,
/^请(?:先|重新)登录/,
/^未登录/,
]
const DEFINITIVE_AUTH_ANYWHERE = /"type"\s*:\s*"authentication_error"|\binvalid_api_key\b|\bincorrect api key provided\b|\binvalid x-api-key\b/i
// Ambiguous refusals must lead a diagnostic line. Tool output such as
// "Failed to download docs: HTTP 403 Forbidden" mentions a refusal of some
// unrelated resource and stays a plain process failure.
const AMBIGUOUS_AUTH_LINE = [
/^HTTP\s+403\s*:/i,
/^(?:403\s+)?forbidden\b/i,
/^unauthori[sz]ed\b/i,
/^access denied\b/i,
/^invalid token\b/i,
]
const AMBIGUOUS_AUTH_ANYWHERE = /"type"\s*:\s*"permission_error"/i
const RATE_LIMIT = /\b429\b|too many requests|rate[ _-]?limit(?:ed|ing)?\b|\boverloaded(?:_error)?\b|\bquota (?:exceeded|exhausted)\b|\bresource[ _-]?exhausted\b|\binsufficient_quota\b|\b529\b/i
const NETWORK = /\b(?:ECONNRESET|ECONNREFUSED|ECONNABORTED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|ENETDOWN|EHOSTUNREACH)\b|socket hang up|fetch failed|network (?:error|is unreachable)|connection (?:reset|refused|closed|timed out)|getaddrinfo|tls handshake|ssl handshake|proxy (?:error|connection)/i
const PROVIDER_UNAVAILABLE = /\b(?:HTTP\s*)?50[0234]\b\s*(?::|-)?\s*(?:internal server error|bad gateway|service unavailable|gateway timeout)|\bservice unavailable\b|\bbad gateway\b|\bgateway timeout\b|\binternal server error\b|"type"\s*:\s*"api_error"|\bupstream connect error\b/i
const CONTEXT_OVERFLOW = /prompt is too long|context[_ ]length[_ ]exceeded|maximum context length|context window (?:exceeded|is full|limit)|input is too long|too many (?:input )?tokens|exceeds? the (?:maximum|model'?s?) context|context_window_exceeded|request too large for model/i
function signalText(value) {
return String(value || '').slice(0, MAX_SIGNAL_TEXT_CHARS)
}
function diagnosticLines(value) {
return signalText(value)
.split(/\r?\n/)
.map(line => line.trim().replace(LOG_PREFIX, '').trim())
.filter(Boolean)
}
// Returns 'definitive', 'ambiguous', or ''.
function authFailureCertainty(value) {
const text = signalText(value)
if (!text.trim()) return ''
const lines = diagnosticLines(text)
if (DEFINITIVE_AUTH_ANYWHERE.test(text)
|| lines.some(line => DEFINITIVE_AUTH_LINE.some(pattern => pattern.test(line)))) {
return 'definitive'
}
if (lines.some(line => AMBIGUOUS_AUTH_LINE.some(pattern => pattern.test(line)))
|| AMBIGUOUS_AUTH_ANYWHERE.test(text)) {
return 'ambiguous'
}
return ''
}
function isRateLimited(value) {
return RATE_LIMIT.test(signalText(value))
}
function isNetworkFailure(value) {
return NETWORK.test(signalText(value))
}
function isProviderUnavailable(value) {
return PROVIDER_UNAVAILABLE.test(signalText(value))
}
function isContextOverflow(value) {
return CONTEXT_OVERFLOW.test(signalText(value))
}
const RETRY_AFTER_PATTERNS = [
/retry[- ]after["']?\s*[:=]?\s*(\d+(?:\.\d+)?)\s*(ms|milliseconds?|s|secs?|seconds?|m|mins?|minutes?)?/i,
/(?:try|retry) again in\s+(\d+(?:\.\d+)?)\s*(ms|milliseconds?|s|secs?|seconds?|m|mins?|minutes?)?/i,
/(?:reset|resets) in\s+(\d+(?:\.\d+)?)\s*(ms|milliseconds?|s|secs?|seconds?|m|mins?|minutes?)/i,
]
const MAX_RETRY_AFTER_MS = 5 * 60 * 1000
function retryAfterFromText(value) {
const text = signalText(value)
for (const pattern of RETRY_AFTER_PATTERNS) {
const match = text.match(pattern)
if (!match) continue
const amount = Number(match[1])
if (!Number.isFinite(amount) || amount < 0) continue
const unit = String(match[2] || 's').toLowerCase()
const multiplier = unit.startsWith('ms') || unit.startsWith('milli')
? 1
: unit.startsWith('m') ? 60 * 1000 : 1000
return Math.min(MAX_RETRY_AFTER_MS, Math.round(amount * multiplier))
}
return null
}
module.exports = {
authFailureCertainty,
isContextOverflow,
isNetworkFailure,
isProviderUnavailable,
isRateLimited,
retryAfterFromText,
}
+139
View File
@@ -0,0 +1,139 @@
const REAL_CLOCK = Object.freeze({
now: () => Date.now(),
setTimeout: (callback, ms) => setTimeout(callback, ms),
clearTimeout: handle => clearTimeout(handle),
})
function positiveFinite(value) {
return Number.isFinite(value) && value > 0
}
// Stops an Agent invocation that has gone quiet. Three windows apply:
// - startup: strict turns (autonomous discussion) must show any sign of life
// within `timeoutMs`; once they do, the deadline is lifted.
// - inactivity: any activity refreshes `timeoutMs`, or `toolTimeoutMs` while a
// tool call is running.
// - stall: strict turns also stop after three inactivity windows without
// substantive progress, so a CLI that emits empty heartbeats forever still
// ends and the remaining participants can continue. Other turns keep
// running while the process is active; the user can stop them.
// Raw process output only proves liveness (`liveness`); mapped events, answer
// text, tool lifecycle and structured progress count as progress (`progress`).
function createInvocationWatchdog({
timeoutMs,
toolTimeoutMs,
strict = false,
isStopped = () => false,
onTimeout = () => {},
clock = REAL_CLOCK,
} = {}) {
const enabled = positiveFinite(timeoutMs)
const startedAt = clock.now()
const startupDeadlineAt = strict && enabled ? startedAt + timeoutMs : Number.POSITIVE_INFINITY
let lastProgressAt = startedAt
let lastSubstantiveAt = startedAt
let paused = false
let established = false
let toolActive = false
let closed = false
let timedOut = false
let timeoutError = null
let timer = null
let rejectTimeout = () => {}
const promise = new Promise((_, reject) => { rejectTimeout = reject })
promise.catch(() => {})
function disarm() {
clock.clearTimeout(timer)
timer = null
}
function arm() {
if (timer || paused || isStopped() || !enabled) return
const now = clock.now()
const inactivityWindowMs = toolActive ? toolTimeoutMs : timeoutMs
const inactivityRemainingMs = Math.max(0, inactivityWindowMs - (now - lastProgressAt))
const startupRemainingMs = Math.max(
0, (established ? Number.POSITIVE_INFINITY : startupDeadlineAt) - now,
)
const stallWindowMs = !strict
? Number.POSITIVE_INFINITY
: (toolActive ? toolTimeoutMs : timeoutMs * 3)
const stallRemainingMs = Math.max(0, stallWindowMs - (now - lastSubstantiveAt))
const remainingMs = Math.min(inactivityRemainingMs, startupRemainingMs, stallRemainingMs)
timer = clock.setTimeout(() => {
timer = null
if (paused || isStopped()) return
timedOut = true
timeoutError = new Error('LOCAL_AGENT_TIMEOUT')
onTimeout(timeoutError)
rejectTimeout(timeoutError)
}, remainingMs)
}
function rearm() {
if (paused) return
disarm()
arm()
}
function inactive() {
return closed || isStopped()
}
// The process produced something meaningful: refresh every window.
function progress() {
if (inactive()) return
lastProgressAt = clock.now()
lastSubstantiveAt = lastProgressAt
rearm()
}
arm()
return {
promise,
get timedOut() { return timedOut },
get error() { return timeoutError },
progress,
// The process is alive but has not said anything yet (stdout/stderr bytes,
// ACP traffic): lift the startup deadline and refresh the inactivity
// window outside tool calls.
liveness() {
if (inactive()) return
established = true
if (!toolActive) lastProgressAt = clock.now()
rearm()
},
// First proof that the run started. Lifts the startup deadline only.
establish() {
if (established || isStopped()) return
established = true
rearm()
},
toolStarted() {
toolActive = true
progress()
},
toolFinished() {
toolActive = false
progress()
},
// Waiting for a person (Human Gate) is not the Agent being quiet.
pause() {
paused = true
disarm()
},
resume() {
paused = false
lastProgressAt = clock.now()
arm()
},
close() {
closed = true
disarm()
},
}
}
module.exports = { createInvocationWatchdog }
+6 -1
View File
@@ -149,7 +149,7 @@ class RunHarness {
return next
}
beginAgent(kind, round = 0, sourceMessageIds = []) {
beginAgent(kind, round = 0, sourceMessageIds = [], options = {}) {
const safeKind = cleanId(kind)
if (!safeKind) throw new Error('RUN_HARNESS_AGENT_REQUIRED')
if (!this.targetKinds.includes(safeKind)) {
@@ -184,6 +184,10 @@ class RunHarness {
seenSeqs: [],
eventCursor: 0,
context: {},
// Per-topic dispatch order; the live bubble and the saved reply share it.
...(Number.isSafeInteger(options?.turnSeq) && options.turnSeq > 0
? { turnSeq: options.turnSeq }
: {}),
}
this.agentRuns.push(run)
while (this.agentRuns.length > this.maxAgentRuns) {
@@ -328,6 +332,7 @@ class RunHarness {
seenSeqs: [...run.seenSeqs],
eventCursor: run.eventCursor,
context: { ...run.context },
...(run.turnSeq ? { turnSeq: run.turnSeq } : {}),
}))
}
}
+1 -1
View File
@@ -311,7 +311,7 @@ class RunLedger {
persist(runs = this.runs) {
atomicWritePrivateFile(
this.storagePath,
`${JSON.stringify({ version: STORE_VERSION, runs }, null, 2)}\n`,
`${JSON.stringify({ version: STORE_VERSION, runs })}\n`,
)
}
-35
View File
@@ -185,8 +185,6 @@ function registerDesktopIpc(options) {
loadKnowledgeBaseSources,
localAgentCatalog,
openExternalUrl,
providerStore,
providerAgentKind,
refreshLocalAgentState,
registerTrustedHandle,
skillCatalog,
@@ -660,39 +658,6 @@ function registerDesktopIpc(options) {
registerTrustedHandle('local-attachments:preview', id => attachments.preview(String(id || '')))
registerTrustedHandle('local-attachments:open', id => attachments.open(String(id || '')))
registerTrustedHandle('local-attachments:discard', ids => attachments.discardUnreferenced(ids))
registerTrustedHandle('local-agent-provider:status', kind => (
providerStore.status(providerAgentKind(kind))
))
registerTrustedHandle('local-agent-provider:probe', kind => (
providerStore.status(providerAgentKind(kind), { probeEncryption: true })
))
registerTrustedHandle('local-agent-provider:save', async (kind, input) => {
const selectedKind = providerAgentKind(kind)
const result = providerStore.save(selectedKind, {
apiKey: input?.apiKey,
provider: input?.provider,
baseUrl: input?.baseUrl,
model: input?.model,
preset: input?.preset,
})
getWorkspace()?.markRuntimeCredential(selectedKind, 'unknown')
await refreshLocalAgentState()
return result
})
registerTrustedHandle('local-agent-provider:activate', async (kind, preset) => {
const selectedKind = providerAgentKind(kind)
const result = providerStore.activate(selectedKind, preset)
getWorkspace()?.markRuntimeCredential(selectedKind, 'unknown')
await refreshLocalAgentState()
return result
})
registerTrustedHandle('local-agent-provider:delete', async (kind, preset) => {
const selectedKind = providerAgentKind(kind)
const result = providerStore.delete(selectedKind, preset)
getWorkspace()?.markRuntimeCredential(selectedKind, 'unknown')
await refreshLocalAgentState()
return result
})
registerTrustedHandle('local-knowledge-connector:list', (...args) => {
if (args.length) throw new Error('LOCAL_KNOWLEDGE_CONNECTOR_REQUEST_INVALID')
const connectors = getKnowledgeConnectors?.()
@@ -0,0 +1,37 @@
const { outboundWirePayloadBytes } = require('../collaboration/outbound-payload.cjs')
// What the Agent was sent: wire bytes are reported, tokens are an estimate.
function recordOutboundUsage(budget, outbound) {
if (!budget) return
const bytes = outboundWirePayloadBytes(outbound)
// A payload that was not built by the transport has no recorded wire size;
// skipping it is better than failing the Agent's turn over bookkeeping.
if (!bytes) return
budget.addUsageBatch([
{ dimension: 'outboundBytes', amount: bytes.length, source: 'reported' },
{ dimension: 'inputTokens', amount: Math.ceil(bytes.length / 4), source: 'estimated' },
])
}
// What the Agent produced: prefer the usage the CLI reported over an estimate.
function recordReplyUsage(budget, { usage, replyText, toolCallCount }) {
if (!budget) return
const reportedOutputTokens = Number(usage?.outputTokens)
const hasReportedOutputTokens = Number.isSafeInteger(reportedOutputTokens)
&& reportedOutputTokens >= 0
const reportedCostMicros = Number(usage?.costMicros)
budget.addUsageBatch([
{
dimension: 'outputTokens',
amount: hasReportedOutputTokens ? reportedOutputTokens : Math.ceil(replyText.length / 4),
source: hasReportedOutputTokens ? 'reported' : 'estimated',
},
{ dimension: 'toolCalls', amount: toolCallCount, source: 'estimated' },
...(Number.isSafeInteger(reportedCostMicros) && reportedCostMicros >= 0
? [{ dimension: 'costMicros', amount: reportedCostMicros, source: 'reported' }]
: []),
{ dimension: 'elapsedMs', value: budget.elapsedValue(), source: 'reported' },
])
}
module.exports = { recordOutboundUsage, recordReplyUsage }
@@ -0,0 +1,72 @@
const { agentStoppedError } = require('./local-workspace-inputs.cjs')
// Marks how far an invocation got before it failed, so the run can decide
// whether the operation may be retried safely.
function attachInvocationFailure(error, input) {
if (!error || (typeof error !== 'object' && typeof error !== 'function')) return error
const existing = error.invocationFailure && typeof error.invocationFailure === 'object'
? error.invocationFailure
: {}
Object.defineProperty(error, 'invocationFailure', {
value: Object.freeze({
outcomeCertainty: existing.outcomeCertainty || input.outcomeCertainty,
sideEffectsPossible: existing.sideEffectsPossible === true || input.sideEffectsPossible === true,
operationId: existing.operationId || input.operationId,
idempotencyMode: existing.idempotencyMode || input.idempotencyMode,
}),
enumerable: false,
configurable: true,
})
return error
}
// Hidden properties on an error that downstream code reads but that must not
// leak into serialised error messages.
function attachErrorMetadata(error, metadata) {
if (!error || (typeof error !== 'object' && typeof error !== 'function')) return error
for (const [name, value] of Object.entries(metadata)) {
if (!value) continue
Object.defineProperty(error, name, { value, enumerable: false, configurable: true })
}
return error
}
// Why an invocation ended and how to report it:
// - the parent run was stopped, timed out or is shutting down;
// - the watchdog cut a silent Agent off;
// - the Agent was cancelled on its own (e.g. a superseded attempt);
// - or it simply failed with `caughtError`.
// `settleOutstanding` tells the caller to wait briefly for capture and run
// promises that may still be winding down.
function resolveInvocationStop({
caughtError, signal, parentAbortObserved, activeRun, agentController, watchdog,
}) {
const parentTimedOut = Boolean(signal?.aborted && activeRun?.stopReason === 'timeout')
const parentStopped = Boolean(signal?.aborted || parentAbortObserved)
const parentInterrupted = parentStopped && activeRun?.stopReason === 'shutdown'
const agentCancelled = agentController.signal.aborted && !parentStopped && !watchdog.timedOut
let error = caughtError
let status = 'failed'
if (parentTimedOut) {
error = new Error('LOCAL_AGENT_TIMEOUT')
status = 'timeout'
} else if (parentStopped) {
error = caughtError?.message === 'LOCAL_AGENT_EXECUTION_STOPPED'
? caughtError
: agentStoppedError()
status = parentInterrupted ? 'interrupted' : 'stopped'
} else if (watchdog.timedOut) {
error = watchdog.error || new Error('LOCAL_AGENT_TIMEOUT')
status = 'timeout'
} else if (agentCancelled) {
error = agentStoppedError()
status = 'stopped'
}
return {
error,
status,
settleOutstanding: parentStopped || watchdog.timedOut || agentCancelled,
}
}
module.exports = { attachErrorMetadata, attachInvocationFailure, resolveInvocationStop }
@@ -0,0 +1,113 @@
const {
MAX_MESSAGE_ATTACHMENTS,
abortableOperation,
normalizeAttachmentMetadata,
} = require('./local-workspace-inputs.cjs')
// Absolute paths an Agent mentions in its reply. Quoted, backticked or
// Markdown-linked paths may contain spaces; bare paths end at whitespace.
function extractReportedOutputPaths(text) {
if (typeof text !== 'string') return []
const root = '\\/(?:Users|home|tmp|private\\/tmp|Volumes)\\/'
const delimited = [...text.matchAll(new RegExp(`[\`"'(<](${root}[^\`"'<>\\n)]+)[\`"')>]`, 'g'))]
.map(match => match[1])
const bare = (text.match(new RegExp(`${root}[^\\s"'<>\`\\]\\[)]+`, 'g')) || [])
.filter(value => !delimited.some(full => full !== value && full.startsWith(value)))
return [...new Set([...delimited, ...bare].map(value => value.trim().replace(/[.,;:!?]+$/, '')))]
}
// Files a writable Agent turn leaves behind: a snapshot of the workspace is
// taken before the turn and compared afterwards to import generated media and
// to describe produced artifacts. Capturing is best effort; only a stop
// request is allowed to interrupt it.
class InvocationOutputs {
constructor({ enabled, workdir, signal, agentKind, startedAt, deps }) {
this.enabled = enabled === true
this.workdir = workdir
this.signal = signal
this.agentKind = agentKind
this.startedAt = startedAt
this.deps = deps
this.baseline = null
this.artifactBaseline = null
this.capturePromise = null
this.artifactCapturePromise = null
this.importPromise = null
this.outcomePromise = null
}
// Promises that may still be running when the invocation is cut short.
pending() {
return [
this.capturePromise,
this.artifactCapturePromise,
this.importPromise,
this.outcomePromise,
].filter(Boolean)
}
async tolerate(operation) {
try {
return await abortableOperation(operation, this.signal)
} catch (error) {
if (this.signal.aborted) throw error
return undefined
}
}
async begin() {
if (!this.enabled) return
this.artifactCapturePromise = Promise.resolve().then(
() => this.deps.captureArtifactOutputs(this.workdir, { signal: this.signal }),
)
this.artifactCapturePromise.catch(() => {})
try {
this.capturePromise = Promise.resolve().then(
() => this.deps.captureAgentOutputs(this.workdir, { signal: this.signal }),
)
this.capturePromise.catch(() => {})
this.baseline = await abortableOperation(() => this.capturePromise, this.signal)
} catch (error) {
if (this.signal.aborted) throw error
}
this.artifactBaseline = (await this.tolerate(() => this.artifactCapturePromise)) ?? null
}
// Media the Agent produced during this turn, as message attachments.
async importMedia(replyText) {
if (!this.enabled) return []
this.importPromise = Promise.resolve().then(() => this.deps.importAgentOutputs({
workdir: this.workdir,
baseline: this.baseline,
startedAt: this.startedAt,
agentKind: this.agentKind,
reportedPaths: extractReportedOutputPaths(replyText),
signal: this.signal,
}))
this.importPromise.catch(() => {})
const imported = await this.tolerate(() => this.importPromise)
return (Array.isArray(imported) ? imported : [])
.slice(0, MAX_MESSAGE_ATTACHMENTS)
.map(normalizeAttachmentMetadata)
.filter(Boolean)
}
// Descriptors of the artifacts the Agent wrote, for the evidence record.
async describeOutcomes({ runId, agentRunId }) {
if (!this.enabled || !this.artifactBaseline) return []
this.outcomePromise = Promise.resolve().then(() => this.deps.captureAgentOutcomeDescriptors({
workdir: this.workdir,
baseline: this.artifactBaseline,
startedAt: this.startedAt,
agentKind: this.agentKind,
runId,
agentRunId,
signal: this.signal,
}))
this.outcomePromise.catch(() => {})
const captured = await this.tolerate(() => this.outcomePromise)
return Array.isArray(captured) ? captured : []
}
}
module.exports = { InvocationOutputs, extractReportedOutputPaths }
@@ -0,0 +1,63 @@
const { stagedAgentInputPrompt } = require('../agents/agent-input-staging.cjs')
const { directPromptFor } = require('./local-workspace-context.cjs')
const { cleanText } = require('./local-workspace-inputs.cjs')
const RESPONSE_SCOPE_INSTRUCTION = [
'Final response scope:',
'Answer only the current user task stated above.',
'Use earlier messages only as supporting evidence when they help answer that current task.',
'Do not separately mention, summarize, continue, or complete any previous user task.',
'Do not append an answer to an older task after the current-task conclusion.',
].join('\n')
// Builds the prompt one Agent receives for one turn.
// - frozen / isolated invocations send exactly the approved prompt.
// - direct chats lead with the user's request; a resumed native session
// already holds the conversation, a new or rebuilt one gets the history.
// - group turns use the V4 prompt when the orchestrator supplied one, else
// the workspace prompt for the current mode.
// Staged attachments and harness recovery instructions are appended to both.
function assembleInvocationPrompt({
group, kind, mode, threadRootId, context, activeRun, isolated, frozen,
v4Prompt, promptMode, afterKind, packed, session, stagedInputs, promptFor, agentLabel,
}) {
// Frozen and isolated prompts are fixed text. Staged attachments are the
// only thing added to a frozen one, so the Agent can open the user's files.
if (isolated || frozen) {
return [v4Prompt, stagedAgentInputPrompt(stagedInputs)].filter(Boolean).join('\n\n')
}
const runtimeInstruction = cleanText(context.runtimeInstruction, 3000)
const regenerationInstruction = cleanText(context.regenerationInstruction, 3000)
const additions = [
stagedAgentInputPrompt(stagedInputs),
runtimeInstruction ? `Harness recovery task:\n${runtimeInstruction}` : '',
regenerationInstruction ? `Fresh response instruction:\n${regenerationInstruction}` : '',
]
if (group.conversationType === 'direct') {
return [
directPromptFor({
group,
label: agentLabel(kind),
packed,
skillHints: context.skillHints || [],
knowledgeBaseHints: context.knowledgeBaseHints || [],
includeHistory: session.rotated || !session.ref,
}),
...additions,
].filter(Boolean).join('\n\n')
}
return [
context.v4 === true && v4Prompt
? v4Prompt
: promptFor(
group, kind, context.completionPolicy === 'typed' ? 'manual' : mode,
threadRootId, context.skillHints || [],
context.knowledgeBaseHints || [], afterKind, packed, promptMode,
activeRun?.unlimitedRounds === true,
),
...additions,
RESPONSE_SCOPE_INSTRUCTION,
].filter(Boolean).join('\n')
}
module.exports = { assembleInvocationPrompt }
@@ -0,0 +1,156 @@
const { createHash, randomUUID } = require('node:crypto')
const { canonicalJson } = require('../collaboration/context-pack-records.cjs')
const { nextSessionMeta, normalizeSessionMeta } = require('../runs/run-harness.cjs')
const MAX_INHERITED_TASK_IDS = 64
function sha256(value) {
return createHash('sha256').update(String(value || '')).digest('hex')
}
function createdSessionProvenance(taskId, stateless = false) {
if (stateless) {
return {
scope: 'none', reuse: false, origin: 'none', originTaskId: null,
inheritedTaskIds: [], completeness: 'complete',
}
}
return {
scope: 'conversation',
reuse: false,
origin: 'created',
originTaskId: taskId || null,
inheritedTaskIds: [],
completeness: 'complete',
}
}
function provenanceMeta(provenance) {
if (provenance.scope === 'unknown-legacy') {
return {
sessionScope: 'unknown-legacy',
originTaskId: '',
inheritedTaskIds: [],
provenanceCompleteness: 'unknown-legacy',
}
}
if (provenance.scope === 'none') return {}
return {
sessionScope: provenance.scope,
originTaskId: provenance.originTaskId || '',
inheritedTaskIds: provenance.inheritedTaskIds || [],
provenanceCompleteness: provenance.completeness,
}
}
function completedSessionMeta(meta, provenance, taskId, usage) {
let inheritedTaskIds = [...(provenance.inheritedTaskIds || [])]
let completeness = provenance.completeness
if (provenance.scope === 'conversation' && taskId
&& taskId !== provenance.originTaskId && !inheritedTaskIds.includes(taskId)) {
inheritedTaskIds.push(taskId)
if (inheritedTaskIds.length > MAX_INHERITED_TASK_IDS) {
inheritedTaskIds = inheritedTaskIds.slice(-MAX_INHERITED_TASK_IDS)
completeness = 'partial'
}
}
return nextSessionMeta(meta, {
...usage,
...provenanceMeta({ ...provenance, inheritedTaskIds, completeness }),
})
}
// Binds a pending permission or connector request to the exact native session
// it was raised in, so a decision cannot be replayed against another session.
function connectorSessionBinding(sessionRef, sessionProvenance) {
const stableProvenance = {
scope: String(sessionProvenance?.scope || 'none'),
originTaskId: String(sessionProvenance?.originTaskId || ''),
inheritedTaskIds: Array.isArray(sessionProvenance?.inheritedTaskIds)
? [...sessionProvenance.inheritedTaskIds]
: [],
completeness: String(sessionProvenance?.completeness || 'complete'),
}
return {
sessionRefHash: sha256(sessionRef),
sessionProvenanceHash: sha256(canonicalJson(stableProvenance)),
}
}
// The native Agent session one invocation runs in. A conversation owns one
// native session for its lifetime: it is resumed on every turn and rebuilt
// only when the Agent reports that the stored session is no longer valid.
class InvocationSession {
// `resolved` is what the workspace stored for this conversation; `deps`
// carries the state hooks so this class never touches workspace state itself.
constructor({ resolved, group, kind, taskId, deps }) {
this.group = group
this.kind = kind
this.taskId = taskId
this.deps = deps
this.key = resolved.key
this.storedRef = String(resolved.sessionRef || '')
this.ref = this.storedRef
this.meta = normalizeSessionMeta(resolved.sessionMeta)
this.provenance = resolved.provenance
this.rotated = resolved.sessionReset === true
this.transport = this.ref ? String(this.meta.transport || '') : ''
}
binding() {
return connectorSessionBinding(this.ref, this.provenance)
}
// Forget the native session so the next turn starts a fresh one. OpenClaw
// addresses its sessions by name, so it gets a new name straight away.
discard() {
this.meta = {}
this.provenance = createdSessionProvenance(this.taskId)
this.transport = ''
this.rotated = true
this.deps.commitSessionState((nextState) => {
delete nextState.sessions[this.key]
delete nextState.sessionMeta[this.key]
if (this.kind === 'openclaw') {
const generation = randomUUID().replace(/[^a-zA-Z0-9]/g, '').slice(0, 12) || 'session'
this.ref = this.deps.openClawSessionRef(
this.group, generation, this.group.conversationType === 'direct' ? '' : this.taskId,
)
nextState.sessions[this.key] = this.ref
nextState.sessionMeta[this.key] = normalizeSessionMeta(provenanceMeta(this.provenance))
} else {
this.ref = ''
}
})
}
// The Agent reported (or created) its native session id.
adopt(nextRef, metadata = {}) {
const transport = ['legacy', 'acp'].includes(metadata?.transport) ? metadata.transport : ''
if (transport) this.transport = transport
this.ref = String(nextRef || '')
this.deps.persistSessionState(this.key, this.ref, normalizeSessionMeta({
...this.meta,
...provenanceMeta(this.provenance),
...(this.transport ? { transport: this.transport } : {}),
}))
}
// A turn finished: record what it cost and which message it had seen.
complete(resultRef, usage) {
this.deps.persistSessionState(
this.key,
resultRef || this.ref,
completedSessionMeta(this.meta, this.provenance, this.taskId, usage),
)
}
}
module.exports = {
InvocationSession,
completedSessionMeta,
connectorSessionBinding,
createdSessionProvenance,
provenanceMeta,
sha256,
}
@@ -4,6 +4,12 @@ const {
const RECENT_VERIFICATION_MS = 24 * 60 * 60 * 1000
const AUTH_FAILURE_RETRY_MS = 30 * 1000
// A readiness probe that times out or crashes says nothing about whether the
// Agent works: it is not a "not signed in" answer. An Agent that was usable
// stays usable through such results for this long; a definitive answer
// (signed out, incompatible, a recorded auth failure) applies immediately.
const INDETERMINATE_PROBE_GRACE_MS = 10 * 60 * 1000
const INDETERMINATE_PROBE_SOURCES = new Set(['native-runtime-unavailable', 'native-probe-failed'])
function expiredAuthFailure(runtime, now) {
if (runtime?.credentialState !== 'missing') return false
@@ -43,7 +49,6 @@ class LocalWorkspaceAgentCatalog {
this.setDetectedAgents = options.setDetectedAgents
this.detectAgents = options.detectAgents
this.credentialState = options.credentialState
this.sharedProviderReady = options.sharedProviderReady
this.attachmentSupport = options.attachmentSupport || (() => ({}))
this.save = options.save
this.emitChanged = options.emitChanged
@@ -51,6 +56,7 @@ class LocalWorkspaceAgentCatalog {
this.now = options.now
this.refreshTask = null
this.refreshAgain = false
this.indeterminateSince = new Map()
}
async refresh() {
@@ -75,17 +81,9 @@ class LocalWorkspaceAgentCatalog {
async refreshOnce() {
const runtimeAtRefreshStart = new Map(Object.entries(this.state().agentRuntime))
const previousAgents = new Map(this.detectedAgents().map(agent => [agent.kind, agent]))
const detected = await this.detectAgents()
const nativeStates = await Promise.all(detected.map(async (agent) => {
const runtime = runtimeAtRefreshStart.get(agent.kind)
const sharedProviderReady = Boolean(this.sharedProviderReady(agent.kind))
if (sharedProviderReady && runtime?.credentialState === 'missing'
&& !expiredAuthFailure(runtime, this.now())) {
return { state: 'missing', source: 'runtime-auth-failure' }
}
if (sharedProviderReady) {
return { state: 'ready', source: 'shared-provider' }
}
try {
return await this.credentialState(agent.kind, agent)
} catch {
@@ -97,11 +95,8 @@ class LocalWorkspaceAgentCatalog {
const agents = detected.map((agent, index) => {
const native = nativeStates[index]
let runtime = state.agentRuntime[agent.kind]
const sharedProviderReady = Boolean(this.sharedProviderReady(agent.kind))
const nativeState = ['ready', 'missing'].includes(native?.state) ? native.state : 'unknown'
const authoritativeNativeState = native?.source === 'native-auth-status'
const sharedProviderRequired = native?.source === 'shared-provider-required'
const providerCredentialUnavailable = native?.source === 'provider-credential-unavailable'
// Give corrected native credentials a retry after the cooldown, never erase a newer failure.
if (expiredAuthFailure(runtime, this.now())
&& !(authoritativeNativeState && nativeState === 'ready')
@@ -129,39 +124,40 @@ class LocalWorkspaceAgentCatalog {
const nativeReadySource = nativeState === 'ready'
? (native.source || 'native-credential')
: ''
const retained = this.retainedAvailability({
agent, previous: previousAgents.get(agent.kind), native, runtimeMissing,
})
let credentialState = 'unknown'
if (runtimeMissing || sharedProviderRequired || nativeState === 'missing') {
if (runtimeMissing || nativeState === 'missing') {
credentialState = 'missing'
}
if (!runtimeMissing && sharedProviderReady) credentialState = 'ready'
else if (!runtimeMissing && nativeState === 'ready') credentialState = 'ready'
if (!runtimeMissing && nativeState === 'ready') credentialState = 'ready'
else if (!runtimeMissing && verifiedReady && nativeState !== 'missing') credentialState = 'ready'
if (providerCredentialUnavailable) credentialState = 'unknown'
if (retained && retained.credentialState === 'ready') credentialState = 'ready'
const installed = true
const versionIdentified = agentVersionIdentified(agent)
const compatible = agentCompatible(agent)
const nativeConfigurationUnknown = !runtimeMissing
&& !providerCredentialUnavailable
&& nativeState === 'unknown'
&& native?.source !== 'native-runtime-unavailable'
&& native?.source !== 'native-probe-failed'
const configured = providerCredentialUnavailable || sharedProviderReady
const configured = retained !== null
|| nativeState === 'ready'
|| native?.source === 'native-auth-status'
|| native?.source === 'native-runtime-unavailable'
|| verifiedReady
|| runtimeMissing
|| nativeConfigurationUnknown
const authenticated = !providerCredentialUnavailable && !runtimeMissing && nativeState !== 'missing' && (
sharedProviderReady || nativeState === 'ready' || verifiedReady || nativeConfigurationUnknown
)
const runtimePrerequisitesReady = native?.source !== 'native-runtime-unavailable'
&& (native?.source !== 'native-probe-failed' || recentlyVerified(runtime, this.now()))
const authenticated = retained !== null || (!runtimeMissing && nativeState !== 'missing' && (
nativeState === 'ready' || verifiedReady || nativeConfigurationUnknown
))
const runtimePrerequisitesReady = retained !== null
|| (native?.source !== 'native-runtime-unavailable'
&& (native?.source !== 'native-probe-failed' || recentlyVerified(runtime, this.now())))
const invocable = installed && versionIdentified && compatible && configured
&& authenticated && runtimePrerequisitesReady
const verifiedRecently = !providerCredentialUnavailable && (
(authoritativeNativeState && nativeState === 'ready') || recentlyVerified(runtime, this.now())
)
const verifiedRecently = (authoritativeNativeState && nativeState === 'ready')
|| recentlyVerified(runtime, this.now())
const available = invocable
const preferred = state.agentPreferences[agent.kind]?.showInSidebar
const capabilities = agentRuntimeCapabilities(agent.kind, {
@@ -170,14 +166,13 @@ class LocalWorkspaceAgentCatalog {
})
let availabilitySource = 'unverified'
if (!compatible) availabilitySource = 'incompatible'
else if (providerCredentialUnavailable) availabilitySource = 'provider-credential-unavailable'
else if (runtimeMissing) availabilitySource = 'runtime-auth-failure'
else if (sharedProviderReady) availabilitySource = nativeReadySource || 'shared-provider'
else if (nativeState === 'missing') availabilitySource = native.source || 'none'
else if (native?.source === 'native-probe-failed') availabilitySource = 'native-probe-failed'
else if (nativeReadySource) availabilitySource = nativeReadySource
else if (verifiedReady) availabilitySource = 'verified-run'
else if (nativeConfigurationUnknown) availabilitySource = 'local-cli'
if (retained) availabilitySource = retained.availabilitySource
return {
...agent,
installed,
@@ -202,6 +197,27 @@ class LocalWorkspaceAgentCatalog {
return this.snapshot()
}
// The state an Agent keeps while a readiness probe is inconclusive, or null
// when the probe result must be taken at face value.
retainedAvailability({ agent, previous, native, runtimeMissing }) {
if (!INDETERMINATE_PROBE_SOURCES.has(native?.source)) {
this.indeterminateSince.delete(agent.kind)
return null
}
const nowMs = Date.parse(String(this.now()))
if (!this.indeterminateSince.has(agent.kind) && Number.isFinite(nowMs)) {
this.indeterminateSince.set(agent.kind, nowMs)
}
const since = this.indeterminateSince.get(agent.kind)
if (previous?.available !== true || runtimeMissing || !Number.isFinite(nowMs)
|| !Number.isFinite(since) || nowMs - since >= INDETERMINATE_PROBE_GRACE_MS
|| !agentVersionIdentified(agent) || !agentCompatible(agent)) return null
return {
credentialState: previous.credentialState,
availabilitySource: previous.availabilitySource || 'native-probe-failed',
}
}
setSidebarVisibility(kind, visible) {
const agent = this.detectedAgents().find(item => item.kind === kind)
if (!agent) throw new Error('LOCAL_AGENT_NOT_INSTALLED')
@@ -219,9 +235,9 @@ class LocalWorkspaceAgentCatalog {
credentialState,
checkedAt: this.now(),
}
if (credentialState === 'ready') this.indeterminateSince.delete(kind)
const agent = this.detectedAgents().find(item => item.kind === kind)
if (agent && agent.availabilitySource !== 'provider-credential-unavailable'
&& !(credentialState === 'unknown' && agent.authenticated === true)) {
if (agent && !(credentialState === 'unknown' && agent.authenticated === true)) {
agent.credentialState = credentialState
agent.versionIdentified = agentVersionIdentified(agent)
agent.compatible = agentCompatible(agent)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,261 @@
// Collaboration records written while an automatic run progresses (roles, objectives, results).
const { createHash } = require('node:crypto')
const {
appendBlackboardEntry,
appendHandoff,
collaborationPackageText,
emptyCollaborationState,
publicCollaborationText,
roleForIndex,
visibleBlackboardEntries,
} = require('../collaboration/collaboration-records.cjs')
const { createTaskGraphCursor } = require('../collaboration/task-graph-records.cjs')
const collaborationMethods = {
orchestrationCursor(targetKinds, taskGraph = null) {
if (taskGraph) {
return {
version: 3,
workflow: 'auto',
template: 'task-graph',
currentKind: '',
pendingKinds: [...targetKinds],
activeKinds: [...targetKinds],
successfulKinds: [],
agreementKinds: [],
attachmentRecipients: [],
totalSuccesses: 0,
terminalFailureOccurred: false,
collaboration: emptyCollaborationState(),
taskGraph: createTaskGraphCursor(taskGraph),
}
}
return {
version: 2,
workflow: 'auto',
currentKind: '',
pendingKinds: [...targetKinds],
activeKinds: [...targetKinds],
successfulKinds: [],
agreementKinds: [],
attachmentRecipients: [],
totalSuccesses: 0,
terminalFailureOccurred: false,
collaboration: emptyCollaborationState(),
}
},
collaborationRole(activeKinds, kind) {
return roleForIndex(Math.max(0, activeKinds.indexOf(kind)))
},
collaborationAudience(role) {
return {
roles: role === 'primary'
? ['reviewer', 'arbiter']
: (role === 'reviewer' ? ['primary', 'arbiter'] : ['primary', 'reviewer']),
agentKinds: [],
}
},
collaborationAcceptance(role) {
if (role === 'reviewer') {
return [
'Check the selected claims and outcome references.',
'Identify unsupported assumptions, conflicts, or missing evidence.',
]
}
if (role === 'arbiter') {
return [
'Resolve visible conflicts using the selected evidence.',
'Return one bounded conclusion and state whether consensus is reached.',
]
}
return [
'Produce one concrete conclusion for the current task.',
'Publish only claims and durable outcome references needed downstream.',
]
},
collaborationExpectedOutput(role) {
if (role === 'reviewer') return 'A concise review with corrections or explicit acceptance.'
if (role === 'arbiter') return 'A final adjudication of the selected claims and conflicts.'
return 'A concise primary conclusion with any durable Artifact or Evidence references.'
},
collaborationObjective(group, controller, threadRootId) {
const root = this.state().messages.find(message => (
message.groupId === group.id && message.id === threadRootId && message.role === 'user'
))
return publicCollaborationText(root?.content || 'Complete the current user task.', 3000)
|| 'Complete the current user task.'
},
prepareCollaborationPackage(
group, controller, activeKinds, kind, threadRootId, options = {},
) {
const collaboration = controller.orchestration?.collaboration || emptyCollaborationState()
const destination = {
agentKind: kind,
role: options.role || this.collaborationRole(activeKinds, kind),
}
const selectedEntryIds = Array.isArray(options.selectedEntryIds)
? new Set(options.selectedEntryIds)
: null
const selectedEntries = selectedEntryIds
? collaboration.entries.filter(entry => selectedEntryIds.has(entry.entryId))
: visibleBlackboardEntries(collaboration, destination)
const objective = this.collaborationObjective(group, controller, threadRootId)
const expectedOutput = options.expectedOutput
|| this.collaborationExpectedOutput(destination.role)
const acceptanceCriteria = options.acceptanceCriteria
|| this.collaborationAcceptance(destination.role)
const selectedIds = selectedEntries.map(entry => entry.entryId)
const reusable = [...collaboration.handoffs].reverse().find(handoff => (
handoff.destination.agentKind === destination.agentKind
&& handoff.destination.role === destination.role
&& handoff.objective === objective
&& handoff.expectedOutput === expectedOutput
&& handoff.selectedEntryIds.length === selectedIds.length
&& handoff.selectedEntryIds.every((id, index) => id === selectedIds[index])
&& handoff.acceptanceCriteria.length === acceptanceCriteria.length
&& handoff.acceptanceCriteria.every((value, index) => value === acceptanceCriteria[index])
))
if (reusable) {
return {
handoff: reusable,
entries: selectedEntries,
text: collaborationPackageText(reusable, selectedEntries),
}
}
const previousOwner = [...collaboration.entries].reverse().find(entry => (
entry.owner.type === 'agent'
))?.owner
const createdAt = Date.now()
const next = appendHandoff(collaboration, {
source: previousOwner || { type: 'harness' },
destination,
objective,
selectedEntryIds: selectedIds,
expectedOutput,
acceptanceCriteria,
provenance: {
runId: controller.runId,
taskId: controller.taskId,
round: controller.currentRound,
agentRunId: null,
artifactIds: [],
evidenceIds: [],
},
createdAt,
})
const handoff = next.handoffs.at(-1)
this.checkpointOrchestration(group, controller, { collaboration: next })
return {
handoff,
entries: selectedEntries,
text: collaborationPackageText(handoff, selectedEntries),
}
},
recordCollaborationResult(group, controller, activeKinds, kind, result, options = {}) {
const role = options.role || this.collaborationRole(activeKinds, kind)
const conclusion = publicCollaborationText(result?.message?.content || '')
const trace = result?.message?.trace
const provenance = {
runId: controller.runId,
taskId: controller.taskId,
round: controller.currentRound,
agentRunId: trace?.agentRunId || null,
artifactIds: result?.outcomeRefs?.artifactIds || [],
evidenceIds: result?.outcomeRefs?.evidenceIds || [],
}
let collaboration = controller.orchestration?.collaboration || emptyCollaborationState()
const previousCollaboration = collaboration
const previousEntryIds = new Set(collaboration.entries.map(entry => entry.entryId))
let sequence = collaboration.entries.length + 1
const recordedAt = Date.now()
const conclusionValue = conclusion
? createHash('sha256').update(conclusion).digest('hex')
: ''
const entryType = options.entryType || 'claim'
const subject = options.subject || `task:${controller.taskId}:conclusion`
const entryRefs = Array.isArray(options.refs) ? options.refs : []
const duplicateConclusion = conclusion && collaboration.entries.some(entry => (
entry.entryType === entryType
&& entry.lifecycle.state === 'active'
&& entry.subject === subject
&& entry.value === conclusionValue
&& entry.owner.type === 'agent'
&& entry.owner.agentKind === kind
))
if (conclusion && !duplicateConclusion) {
collaboration = appendBlackboardEntry(collaboration, {
entryType,
subject,
statement: conclusion,
value: entryType === 'claim' ? conclusionValue : '',
owner: { type: 'agent', agentKind: kind, role },
audience: this.collaborationAudience(role),
lifecycle: {
state: 'active', sequence, recordedAt, supersedesEntryId: null,
},
provenance,
refs: entryRefs,
})
sequence = collaboration.entries.length + 1
}
for (const artifactId of duplicateConclusion ? [] : provenance.artifactIds.slice(0, 1)) {
collaboration = appendBlackboardEntry(collaboration, {
entryType: 'artifact-ref',
subject: `task:${controller.taskId}:artifact`,
statement: 'Durable Artifact produced by the Agent.',
value: '',
owner: { type: 'agent', agentKind: kind, role },
audience: this.collaborationAudience(role),
lifecycle: {
state: 'active', sequence, recordedAt, supersedesEntryId: null,
},
provenance,
refs: [artifactId],
})
sequence = collaboration.entries.length + 1
}
for (const evidenceId of duplicateConclusion ? [] : provenance.evidenceIds.slice(0, 1)) {
collaboration = appendBlackboardEntry(collaboration, {
entryType: 'evidence-ref',
subject: `task:${controller.taskId}:evidence`,
statement: 'Durable Evidence recorded for the Agent conclusion.',
value: '',
owner: { type: 'agent', agentKind: kind, role },
audience: this.collaborationAudience(role),
lifecycle: {
state: 'active', sequence, recordedAt, supersedesEntryId: null,
},
provenance,
refs: [evidenceId],
})
sequence = collaboration.entries.length + 1
}
if (collaboration !== previousCollaboration) {
this.checkpointOrchestration(group, controller, { collaboration })
}
return collaboration.entries
.filter(entry => !previousEntryIds.has(entry.entryId))
.map(entry => entry.entryId)
},
checkpointOrchestration(group, controller, updates = {}) {
const previous = controller.orchestration
controller.orchestration = { ...previous, ...updates }
// A thrown callback has an unknown write outcome; only explicit failure is safe to roll back.
const persisted = this.checkpointRun?.(group.id, controller)
if (this.hasRunLedger() && persisted !== true) {
controller.orchestration = previous
if (previous?.version === 4) controller.currentRound = previous.round
throw new Error('LOCAL_RUN_PERSIST_FAILED')
}
},
}
module.exports = { collaborationMethods }
@@ -1,61 +1,117 @@
const { parseTaskDecision } = require('../collaboration/task-decision.cjs')
const { parseLenientTaskDecision } = require('../collaboration/task-decision.cjs')
const v4NaturalDecisionMethods = {
v4NaturalRouteDecision(decision, activeKinds) {
const participants = Array.isArray(activeKinds) ? activeKinds : []
if (decision == null) return { status: 'none', kinds: [] }
if (decision == null) return { status: 'none', kinds: [], dropped: [] }
let parsed
try { parsed = parseTaskDecision(decision) }
catch { return { status: 'invalid', kinds: [] } }
try { parsed = parseLenientTaskDecision(decision) }
catch { return { status: 'none', kinds: [], dropped: [] } }
const requested = parsed.nextKinds || []
if (requested.some(kind => !participants.includes(kind))) return { status: 'invalid', kinds: [] }
if (!requested.length) return { status: 'none', kinds: [] }
return { status: 'valid', kinds: participants.filter(kind => requested.includes(kind)) }
// A request for an absent or paused participant is dropped on its own;
// it used to stop the whole discussion with a misleading message.
const dropped = requested.filter(kind => !participants.includes(kind))
const kinds = participants.filter(kind => requested.includes(kind))
return { status: kinds.length ? 'valid' : 'none', kinds, dropped }
},
v4NaturalNotice(group, controller, threadRootId, key, text, params = {}) {
const runParams = { ...params, runId: controller.runId }
const duplicate = this.state().messages.some(message => (
message.threadRootId === threadRootId
&& message.system?.key === key
&& JSON.stringify(message.system?.params || {}) === JSON.stringify(runParams)
))
if (!duplicate) {
this.addMessage(group.id, 'system', text, '', threadRootId, { key, params: runParams })
}
},
async v4NaturalRoundOutcome(group, controller, threadRootId, round, activeKinds) {
const messages = this.v4NaturalDiscussionRoundMessages(group, controller, threadRootId, round)
const owner = this.v4NaturalOwner(group, controller, threadRootId)
if (!activeKinds.length) return { status: 'partial' }
const nominalOwner = this.v4NaturalOwner(group, controller, threadRootId)
const owner = this.v4NaturalOwner(group, controller, threadRootId, activeKinds)
if (owner !== nominalOwner) {
// The owner failed or was paused: the next selected participant takes
// over instead of the discussion ending silently as partial.
this.v4NaturalNotice(group, controller, threadRootId, 'system.autoOwnerChanged',
`${this.agentLabel(owner)} took over as delivery owner because ${this.agentLabel(nominalOwner)} is no longer available.`,
{ agent: this.agentLabel(owner), previous: this.agentLabel(nominalOwner) })
}
const routes = messages.map(message => this.v4NaturalRouteDecision(
message.trace?.context?.taskDecision, activeKinds,
))
if (routes.some(route => route.status === 'invalid')) {
this.addMessage(group.id, 'system', 'Discussion stopped without a valid task completion decision.',
'', threadRootId, { key: 'system.autoTaskDecisionMissing' })
return { status: 'partial' }
const dropped = [...new Set(routes.flatMap(route => route.dropped || []))]
if (dropped.length) {
this.v4NaturalNotice(group, controller, threadRootId, 'system.autoRouteIgnored',
`Requests for ${dropped.map(kind => this.agentLabel(kind)).join(', ')} were skipped because they are not active in this discussion.`,
{ agents: dropped.map(kind => this.agentLabel(kind)).join(', ') })
}
// A peer's explicit blocker is a task outcome, not an empty routing choice.
// Without this guard the delivery owner could complete the run while a
// participant had already reported that the work was blocked.
const ownerMessage = messages.find(message => message.agentKind === owner)
// A peer's blocker is input for the delivery owner, who judges the whole
// task. It used to end the discussion as partial on the spot.
const peerBlocker = messages.find(message => {
if (message.agentKind === owner) return false
const status = message.trace?.context?.taskDecision?.status
return status === 'blocked' || status === 'needs-human'
})
if (peerBlocker) {
const decision = peerBlocker.trace?.context?.taskDecision
this.addMessage(group.id, 'system', decision.reason, '', threadRootId, {
key: decision.status === 'needs-human' ? 'system.autoTaskNeedsHuman' : 'system.autoTaskBlocked',
params: { reason: decision.reason },
})
return { status: 'partial' }
const ownerSawBlocker = peerBlocker && ownerMessage
&& controller.discussionStyle === 'sequential'
&& messages.indexOf(ownerMessage) > messages.indexOf(peerBlocker)
if (peerBlocker && !ownerSawBlocker) {
const peerDecision = peerBlocker.trace?.context?.taskDecision
this.v4NaturalNotice(group, controller, threadRootId, 'system.autoPeerBlockerForwarded',
`${this.agentLabel(peerBlocker.agentKind)} reported a blocker; the delivery owner will decide how to proceed.`,
{ agent: this.agentLabel(peerBlocker.agentKind), reason: peerDecision.reason })
return { nextKinds: [owner], ownerReview: true }
}
const nextKinds = this.v4NaturalNextKinds(group, controller, threadRootId, round, activeKinds)
if (nextKinds.length) return { nextKinds, ownerReview: false }
if (!activeKinds.includes(owner)) return { status: 'partial' }
const ownerMessage = messages.find(message => message.agentKind === owner)
const decision = ownerMessage?.trace?.context?.taskDecision
if (decision?.handoffTo && !activeKinds.includes(decision.handoffTo)) return { status: 'partial' }
if (decision?.handoffTo && !activeKinds.includes(decision.handoffTo)) {
this.v4NaturalNotice(group, controller, threadRootId, 'system.autoHandoffIgnored',
`The handoff to ${this.agentLabel(decision.handoffTo)} was ignored because that Agent is not active; ${this.agentLabel(owner)} remains the delivery owner.`,
{ agent: this.agentLabel(decision.handoffTo), owner: this.agentLabel(owner) })
}
if (decision?.status === 'continue') return { nextKinds: [owner], ownerReview: false }
const sawAllContributions = messages.length === 1
|| (controller.discussionStyle === 'sequential' && messages.at(-1)?.agentKind === owner)
if (!ownerMessage || !sawAllContributions) return { nextKinds: [owner], ownerReview: true }
if (!decision) {
// Ask the owner once more for a judgment before giving up: a missing
// or unreadable receipt is usually a formatting slip, not a verdict.
if (!this.v4NaturalOwnerMissedDecisionBefore(group, controller, threadRootId, round, owner)) {
this.v4NaturalNotice(group, controller, threadRootId, 'system.autoTaskDecisionRequested',
`${this.agentLabel(owner)} did not include a readable task decision; asking for one before continuing.`,
{ agent: this.agentLabel(owner), round })
return { nextKinds: [owner], ownerReview: true }
}
this.addMessage(group.id, 'system', 'Discussion stopped without a valid task completion decision.',
'', threadRootId, { key: 'system.autoTaskDecisionMissing' })
return { status: 'partial' }
}
if (decision.status === 'completed') return { status: controller.failedKinds.length ? 'partial' : 'completed' }
if (decision.status === 'completed') {
// The owner may not close the task before selected peers had one discussion
// round of their own; otherwise a single full round collapses into an
// owner-only review and peers never get to challenge the result.
if (activeKinds.some(kind => kind !== owner)
&& !this.v4NaturalPriorPeerDiscussion(group, controller, threadRootId, round, owner)) {
this.addMessage(
group.id, 'system',
'The delivery owner signaled completion before peer discussion; running one cross-review round first.',
'', threadRootId, { key: 'system.autoCrossReview' },
)
return { nextKinds: [...activeKinds], ownerReview: false }
}
if (controller.failedKinds.length) {
this.v4NaturalNotice(group, controller, threadRootId, 'system.autoCompletedWithout',
`The delivery owner completed the task without ${controller.failedKinds.map(kind => this.agentLabel(kind)).join(', ')}, which stopped earlier in this discussion.`,
{ agents: controller.failedKinds.map(kind => this.agentLabel(kind)).join(', ') })
return { status: 'partial' }
}
return { status: 'completed' }
}
if (decision.status === 'needs-human' && this.requestTaskDecision) {
const response = await this.requestTaskDecision(group, controller, ownerMessage)
if (response?.status === 'approved') return { nextKinds: [owner], ownerReview: true }
@@ -81,7 +137,37 @@ const v4NaturalDecisionMethods = {
return activeKinds.filter(kind => selected.has(kind))
},
v4NaturalOwner(group, controller, threadRootId) {
v4NaturalPriorPeerDiscussion(group, controller, threadRootId, round, owner) {
return this.state().messages.some(message => (
message.groupId === group.id
&& message.threadRootId === threadRootId
&& message.role === 'agent'
&& message.agentKind !== owner
&& message.trace?.runId === controller.runId
&& message.trace?.phase === 'discussion'
&& Number(message.trace?.round) < round
&& this.v4NaturalMessageMatchesBinding(message, controller)
))
},
v4NaturalOwnerMissedDecisionBefore(group, controller, threadRootId, round, owner) {
const earlier = this.state().messages.filter(message => (
message.groupId === group.id
&& message.threadRootId === threadRootId
&& message.role === 'agent'
&& message.agentKind === owner
&& message.trace?.runId === controller.runId
&& message.trace?.phase === 'discussion'
&& Number(message.trace?.round) < round
&& this.v4NaturalMessageMatchesBinding(message, controller)
))
const previous = earlier.at(-1)
return Boolean(previous && !previous.trace?.context?.taskDecision)
},
// With activeKinds, an owner who failed or was paused hands ownership to
// the first still-active selected participant.
v4NaturalOwner(group, controller, threadRootId, activeKinds = null) {
let owner = controller.targetKinds[0] || ''
for (const message of this.state().messages) {
if (message.groupId !== group.id || message.threadRootId !== threadRootId
@@ -92,6 +178,9 @@ const v4NaturalDecisionMethods = {
if (decision?.status === 'continue' && decision.handoffTo
&& controller.targetKinds.includes(decision.handoffTo)) owner = decision.handoffTo
}
if (Array.isArray(activeKinds) && activeKinds.length && !activeKinds.includes(owner)) {
return controller.targetKinds.find(kind => activeKinds.includes(kind)) || owner
}
return owner
},
@@ -0,0 +1,540 @@
// Natural discussion, the production path for automatic group runs: transcript, prompts and the sequential and agent-led loops.
const path = require('node:path')
const { MAX_MESSAGE_TEXT_CHARS } = require('./local-workspace-contracts.cjs')
const { hashValue } = require('../collaboration/orchestration-v4-records.cjs')
const { packNaturalDiscussionTranscript, v4Prompt } = require('./local-workspace-context.cjs')
const { naturalDiscussionPrompt } = require('./prompts/natural-discussion-prompt.cjs')
const { terminalRunStatusForReason } = require('./local-workspace-auto-runner-support.cjs')
const { traceCapsuleFromAgentRun } = require('../runs/run-harness-normalization.cjs')
const naturalDiscussionMethods = {
v4NaturalMessageMatchesBinding(message, controller) {
const phase = String(message?.trace?.phase || '')
const kind = String(message?.agentKind || '')
const round = Number(message?.trace?.round) || 0
if (!['proposal', 'discussion'].includes(phase) || !kind || round < 1) return false
if (message.trace?.runId !== controller.runId) return false
const slot = controller.orchestration?.slots?.find(candidate => candidate.agentKind === kind)
const snapshotHash = String(controller.orchestration?.snapshotHash || '')
if (!slot?.slotId || !snapshotHash) return false
const operationPhase = phase === 'discussion' ? `discussion:${round}` : phase
const expectedOperationId = this.v4OperationId(
controller, kind, operationPhase, slot.slotId, round,
)
return message.trace?.context?.operationId === expectedOperationId
&& message.trace?.context?.snapshotHash === snapshotHash
},
v4NaturalMessageContent(message) {
const text = String(message.content || '')
if (text.length < MAX_MESSAGE_TEXT_CHARS && message.trace?.truncated !== true) {
return { text, partial: false }
}
for (const artifactId of message.trace?.context?.outcomeRefs?.artifactIds || []) {
try {
const { artifact, content } = this.v4ArtifactIdentity(artifactId)
if (artifact.name !== `${message.agentKind}-conclusion.txt`
|| artifact.producedBy?.runId !== message.trace?.runId
|| artifact.producedBy?.agentRunId !== message.trace?.agentRunId
|| artifact.producedBy?.agentKind !== message.agentKind
|| !content.startsWith(text)) continue
return { text: content, partial: false }
} catch { /* Historical blobs may be unavailable; identify the excerpt below. */ }
}
return { text, partial: true }
},
v4NaturalThreadEntries(group, threadRootId, options = {}) {
const rounds = Array.isArray(options.rounds) ? new Set(options.rounds) : null
const entries = this.state().messages.filter(message => (
message.groupId === group.id
&& message.threadRootId === threadRootId
&& message.role === 'agent'
&& message.agentKind
&& (!rounds || rounds.has(Number(message.trace?.round) || 0))
&& (!options.controller || this.v4NaturalMessageMatchesBinding(
message, options.controller,
))
)).map((message, index) => ({ message, index }))
// Peers read turns in dispatch order, the same order the user sees,
// instead of the order replies happened to be saved in.
.sort((left, right) => {
const leftTurn = Number.isSafeInteger(left.message.turnSeq) ? left.message.turnSeq : 0
const rightTurn = Number.isSafeInteger(right.message.turnSeq) ? right.message.turnSeq : 0
return (leftTurn ? 1 : 0) - (rightTurn ? 1 : 0)
|| leftTurn - rightTurn
|| left.index - right.index
})
.map(({ message }) => message)
.map(message => ({
...this.v4NaturalMessageContent(message),
id: message.id,
agentKind: message.agentKind,
round: Number(message.trace?.round) || 0,
phase: message.trace?.phase || 'proposal',
operationId: message.trace?.context?.operationId || '',
})).map(entry => ({
// Peers collaborating on the same workspace must be able to name its
// files; keep workspace paths readable as relative paths.
...entry, text: this.v4SanitizeDeliveryText(entry.text, Number.MAX_SAFE_INTEGER, {
workspaceRoot: group.workdir ? path.resolve(group.workdir) : '',
}),
}))
return entries.map(entry => ({ ...entry, hash: hashValue(entry) }))
},
v4NaturalThreadTranscript(group, threadRootId, options = {}) {
return packNaturalDiscussionTranscript(this.v4NaturalThreadEntries(group, threadRootId, options))
},
v4NaturalDeliveryPrompt(group, controller, threadRootId, kind, slot, binding, promptBuilder) {
const entries = this.v4NaturalThreadEntries(group, threadRootId, { controller })
const previousEntries = binding.hasSession && !binding.sessionRotated
? (controller.orchestration?.deliveryState || []).filter(entry => (
entry.recipientKind === kind && entry.status === 'acknowledged'
&& entry.sessionRefHash === binding.sessionRefHash
&& entry.sessionProvenanceHash === binding.sessionProvenanceHash
&& entry.snapshotHash === controller.orchestration.snapshotHash
&& entry.sourceMessages
))
: []
const seen = new Set(previousEntries.flatMap(entry => entry.sourceMessages.map(message => (
[entry.sourceAgentKind, entry.sourcePhase, message.id, message.hash].join('\u0000')
))))
const unseen = entries.filter(entry => !seen.has([
entry.agentKind, entry.phase, entry.id, entry.hash,
].join('\u0000')))
const packed = packNaturalDiscussionTranscript(unseen, {
withEntries: true,
prefix: unseen.length < entries.length
? 'Previously delivered turns are not repeated here. They were supplied to this native session; ask peers to restate missing evidence if your current context is incomplete. Their omission does not imply agreement or task completion.'
: '',
})
const included = new Set(packed.completeMessageIds)
const sources = new Map()
for (const entry of unseen) {
if (!included.has(entry.id)) continue
const key = `${entry.agentKind}\u0000${entry.phase}`
const source = sources.get(key) || {
sourceAgentKind: entry.agentKind, sourcePhase: entry.phase,
watermark: 0, operationId: entry.operationId, sourceMessages: [],
}
source.watermark = Math.max(source.watermark, entry.round)
source.sourceMessages.push({ id: entry.id, hash: entry.hash })
sources.set(key, source)
}
const packageHash = hashValue(packed.text)
const deliveryId = `delivery-${hashValue({
runId: controller.runId, kind, operationId: slot.operationId,
packageHash, binding, now: Date.now(),
})}`
const delivery = sources.size ? {
natural: true, previousEntries, packageHash, deliveryId,
entries: [...sources.values()].map(source => ({
...source, recipientKind: kind,
sessionRefHash: binding.sessionRefHash,
sessionProvenanceHash: binding.sessionProvenanceHash,
snapshotHash: controller.orchestration.snapshotHash,
packageHash, deliveryId, status: 'prepared', updatedAt: Date.now(),
})),
} : null
this.v4PrepareDelivery(group, controller, delivery)
return { prompt: promptBuilder(packed.text), delivery }
},
v4NaturalRecoveredPhaseResult(group, controller, threadRootId, phase, kind, slot) {
if (!this.naturalAgentResponses || !slot || controller.currentRound < 1) return null
if (!['proposal', 'discussion'].includes(phase)) return null
const operationId = String(slot.operationId || '')
const snapshotHash = String(slot.snapshotHash || '')
if (!operationId || !snapshotHash) return null
const matchesBinding = context => (
context?.operationId === operationId && context?.snapshotHash === snapshotHash
)
const scopedMessages = [...this.state().messages].reverse().filter(message => (
message.groupId === group.id
&& message.threadRootId === threadRootId
&& message.role === 'agent'
&& message.agentKind === kind
&& message.trace?.runId === controller.runId
&& message.trace?.phase === phase
&& Number(message.trace?.round) === controller.currentRound
))
if (scopedMessages.some(message => !matchesBinding(message.trace?.context))) {
throw new Error('LOCAL_RUN_COLLABORATION_SCOPE_INVALID')
}
const existingMessage = scopedMessages.find(message => matchesBinding(message.trace?.context))
let content = String(existingMessage?.content || '')
let trace = existingMessage?.trace || null
let metadata = existingMessage ? {
...(existingMessage.elapsedMs != null ? { elapsedMs: existingMessage.elapsedMs } : {}),
...(Array.isArray(existingMessage.toolCalls) ? { toolCalls: existingMessage.toolCalls } : {}),
...(Array.isArray(existingMessage.attachments)
? { attachments: existingMessage.attachments } : {}),
...(existingMessage.responseVersionRootId
? { responseVersionRootId: existingMessage.responseVersionRootId } : {}),
trace,
} : null
if (!content || !trace?.agentRunId) {
const scopedRuns = [...(controller.harness?.snapshot?.() || [])].reverse().filter(run => (
run.kind === kind
&& run.round === controller.currentRound
&& run.status === 'completed'
&& String(run.output || '')
))
if (scopedRuns.some(run => !matchesBinding(run.context))) {
throw new Error('LOCAL_RUN_COLLABORATION_SCOPE_INVALID')
}
const recoveredRun = scopedRuns.find(run => matchesBinding(run.context))
if (!recoveredRun) return null
content = String(recoveredRun.output)
trace = traceCapsuleFromAgentRun(recoveredRun, {
runId: controller.runId,
status: recoveredRun.status,
phase,
})
if (!trace?.agentRunId) return null
metadata = { trace }
}
return {
text: content,
pendingMessage: {
groupId: group.id,
role: 'agent',
content,
agentKind: kind,
threadRootId,
system: null,
metadata,
},
collaboration: trace.context?.taskDecision
? { summary: trace.context.taskDecision.reason, taskDecision: trace.context.taskDecision }
: null,
outcomeRefs: trace.context?.outcomeRefs || {},
operationId: slot.operationId,
consensus: false,
}
},
v4NaturalRecoveredDiscussionResult(group, controller, threadRootId, kind, slot) {
return this.v4NaturalRecoveredPhaseResult(
group, controller, threadRootId, 'discussion', kind, slot,
)
},
v4NaturalPhasePrompt(group, kind, phase, snapshot, receiptRecords, activeKinds, options = {}) {
const humanResponses = options.runId ? this.taskDecisionResponses(options.runId) : []
return naturalDiscussionPrompt({
brief: v4Prompt({
group, kind, phase, snapshot, role: 'participant',
skillHints: options.skillHints,
knowledgeBaseHints: options.knowledgeBaseHints,
naturalResponse: true,
}),
group,
kind,
phase,
activeKinds,
transcript: String(options.transcript || ''),
humanResponsesText: humanResponses.length ? packNaturalDiscussionTranscript(humanResponses) : '',
writerKind: options.writerKind,
ownerKind: options.ownerKind,
allowRouting: options.allowRouting === true,
})
},
v4NaturalDiscussionOperationIds(controller, slots, kinds, round) {
return new Map(kinds.map((kind) => {
const slot = slots.find(candidate => candidate.agentKind === kind)
return [kind, this.v4OperationId(
controller, kind, `discussion:${round}`, slot?.slotId || '', round,
)]
}))
},
v4NaturalDiscussionRoundMessages(group, controller, threadRootId, round) {
return this.state().messages.filter(message => (
message.groupId === group.id
&& message.threadRootId === threadRootId
&& message.role === 'agent'
&& message.trace?.runId === controller.runId
&& message.trace?.phase === 'discussion'
&& Number(message.trace?.round) === round
&& this.v4NaturalMessageMatchesBinding(message, controller)
))
},
async runV4SequentialDiscussion(group, controller, threadRootId, context, input) {
const {
targetKinds, activeKinds: initialActiveKinds, writerKind, batchId,
snapshot, snapshotRecord, snapshotHash, slots: initialSlots, receiptRecords,
checkpointPhase, removePhaseFailures, addRoundLimitNotice,
} = input
let activeKinds = [...initialActiveKinds]
let slots = initialSlots
let round = Math.max(1, Number(controller.currentRound) || 1)
let pendingKinds = controller.orchestration?.discussionStyle === 'sequential'
&& controller.orchestration?.phase === 'discussion'
&& controller.orchestration?.round === round
? (controller.orchestration.pendingKinds || []).filter(kind => activeKinds.includes(kind))
: [...activeKinds]
if (!pendingKinds.length && controller.orchestration?.phase === 'discussion') {
const outcome = await this.v4NaturalRoundOutcome(group, controller, threadRootId, round, activeKinds)
if (outcome.status) return outcome.status
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) {
addRoundLimitNotice()
return 'round-limit'
}
round += 1
pendingKinds = outcome.ownerReview ? outcome.nextKinds : [...activeKinds]
}
while (!controller.signal.aborted) {
controller.currentRound = round
while (pendingKinds.length && !controller.signal.aborted) {
const kind = pendingKinds[0]
const remainingKinds = pendingKinds.slice(1)
const dispatch = await this.runV4Phase(group, controller, threadRootId, context, {
phase: 'discussion',
activeKinds: [kind],
targetKinds,
participantKinds: activeKinds,
writerKind,
batchId,
snapshot,
snapshotRecord,
snapshotHash,
slots,
receiptRecords,
challengeBindings: null,
coordinationPlan: null,
workReceipts: [],
operationIds: this.v4NaturalDiscussionOperationIds(
controller, slots, [kind], round,
),
pendingKinds,
remainingKinds,
sessionPolicy: null,
resumedGate: input.resumedGate,
disableDeliveryPrompt: true,
promptBuilder: ({ kind: promptKind, transcript }) => this.v4NaturalPhasePrompt(
group, promptKind, 'discussion', snapshot, receiptRecords, activeKinds, {
runId: controller.runId,
transcript: transcript ?? this.v4NaturalThreadTranscript(
group, threadRootId, { controller },
),
skillHints: context.rootSkillsByKind.get(promptKind) || [],
knowledgeBaseHints: context.rootKnowledgeBasesByKind.get(promptKind) || [],
ownerKind: this.v4NaturalOwner(group, controller, threadRootId, activeKinds),
writerKind,
allowRouting: false,
},
),
})
slots = dispatch.slots || slots
if (!dispatch.ok) {
if (controller.signal.aborted) return terminalRunStatusForReason(controller.stopReason)
const failedKinds = removePhaseFailures(dispatch)
activeKinds = activeKinds.filter(agentKind => !failedKinds.includes(agentKind))
if (!activeKinds.length) {
return dispatch.phasePendingMessages?.length ? 'partial' : 'failed'
}
}
pendingKinds = remainingKinds.filter(agentKind => activeKinds.includes(agentKind))
}
const outcome = await this.v4NaturalRoundOutcome(group, controller, threadRootId, round, activeKinds)
if (outcome.status) return outcome.status
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) {
addRoundLimitNotice()
return 'round-limit'
}
round += 1
controller.currentRound = round
pendingKinds = outcome.ownerReview ? outcome.nextKinds : [...activeKinds]
}
return terminalRunStatusForReason(controller.stopReason)
},
async runV4AgentLedDiscussion(group, controller, threadRootId, context, input) {
const {
targetKinds, activeKinds: initialActiveKinds, writerKind, batchId,
snapshot, snapshotRecord, snapshotHash, slots: initialSlots, receiptRecords,
latestFor, runPhase, checkpointPhase, removePhaseFailures, addRoundLimitNotice,
} = input
let activeKinds = [...initialActiveKinds]
let slots = initialSlots
let round = Math.max(1, Number(controller.currentRound) || 1)
const proposalCompleteBeforeRun = activeKinds.every(kind => Boolean(latestFor('proposal', kind)))
if (!proposalCompleteBeforeRun) {
controller.currentRound = 1
const proposal = await runPhase('proposal', activeKinds)
slots = proposal.slots || slots
if (!proposal.ok) {
const failedKinds = removePhaseFailures(proposal)
activeKinds = activeKinds.filter(kind => !failedKinds.includes(kind))
checkpointPhase('proposal', [], slots, null)
if (!activeKinds.length) return proposal.phasePendingMessages?.length ? 'partial' : 'failed'
}
}
const proposalComplete = activeKinds.every(kind => Boolean(latestFor('proposal', kind)))
if (!proposalComplete) {
return activeKinds.some(kind => Boolean(latestFor('proposal', kind))) ? 'partial' : 'failed'
}
if (!controller.unlimitedRounds && (controller.maxRounds || 6) <= 1) {
addRoundLimitNotice()
return 'round-limit'
}
let nextKinds = []
if (controller.orchestration?.phase === 'discussion') {
round = Math.max(2, Number(controller.orchestration.round) || 2)
nextKinds = (controller.orchestration.pendingKinds || [])
.filter(kind => activeKinds.includes(kind))
if (!nextKinds.length) {
if (this.v4NaturalStopRepeatingDiscussion(
group, controller, threadRootId, round, activeKinds,
)) return 'partial'
const outcome = await this.v4NaturalRoundOutcome(group, controller, threadRootId, round, activeKinds)
if (outcome.status) return outcome.status
nextKinds = outcome.nextKinds
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) {
addRoundLimitNotice()
return 'round-limit'
}
round += 1
}
} else {
round = 2
// The first discussion round is a full participant round: every selected
// Agent reacts to the proposal set before the delivery owner reviews and
// judges completion, so a group run cannot collapse to owner-only turns.
nextKinds = [...activeKinds]
}
while (!controller.signal.aborted) {
controller.currentRound = round
const dispatch = await this.runV4Phase(group, controller, threadRootId, context, {
phase: 'discussion',
activeKinds: nextKinds,
targetKinds,
participantKinds: activeKinds,
writerKind,
batchId,
snapshot,
snapshotRecord,
snapshotHash,
slots,
receiptRecords,
challengeBindings: null,
coordinationPlan: null,
workReceipts: [],
operationIds: this.v4NaturalDiscussionOperationIds(
controller, slots, nextKinds, round,
),
pendingKinds: nextKinds,
remainingKinds: [],
sessionPolicy: null,
resumedGate: input.resumedGate,
disableDeliveryPrompt: true,
promptBuilder: ({ kind, transcript }) => this.v4NaturalPhasePrompt(
group, kind, 'discussion', snapshot, receiptRecords, activeKinds, {
runId: controller.runId,
transcript: transcript ?? this.v4NaturalThreadTranscript(group, threadRootId, { controller }),
skillHints: context.rootSkillsByKind.get(kind) || [],
knowledgeBaseHints: context.rootKnowledgeBasesByKind.get(kind) || [],
allowRouting: true,
ownerKind: this.v4NaturalOwner(group, controller, threadRootId, activeKinds),
writerKind,
},
),
})
slots = dispatch.slots || slots
if (!dispatch.ok) {
if (controller.signal.aborted) return terminalRunStatusForReason(controller.stopReason)
const failedKinds = removePhaseFailures(dispatch)
activeKinds = activeKinds.filter(kind => !failedKinds.includes(kind))
if (!activeKinds.length) return dispatch.phasePendingMessages?.length ? 'partial' : 'failed'
}
if (this.v4NaturalStopRepeatingDiscussion(
group, controller, threadRootId, round, activeKinds,
)) return 'partial'
const outcome = await this.v4NaturalRoundOutcome(group, controller, threadRootId, round, activeKinds)
if (outcome.status) return outcome.status
nextKinds = outcome.nextKinds
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) {
addRoundLimitNotice()
return 'round-limit'
}
round += 1
controller.currentRound = round
}
return terminalRunStatusForReason(controller.stopReason)
},
async runV4NaturalDiscussion(group, controller, threadRootId, context, input) {
if (input.resume && controller.orchestration?.phase === 'discussion') {
for (const slot of input.slots) {
if (slot.permission !== 'workspace-write' || !slot.agentRunId
|| slot.commitStatus === 'committed'
|| !['running', 'waiting', 'stopped', 'failed'].includes(slot.status)) continue
if (this.v4NaturalRecoveredDiscussionResult(group, controller, threadRootId, slot.agentKind, slot)) continue
if (input.resumedGate?.operationId === slot.operationId
&& input.resumedGate.agentKind === slot.agentKind
&& input.resumedGate.status === 'approved') continue
if (!this.requestHumanGate) throw new Error('LOCAL_RUN_RETRY_GATE_INVALID')
const binding = {
agentRunId: slot.agentRunId, agentKind: slot.agentKind,
round: controller.currentRound, phase: 'discussion', slotId: slot.slotId,
operationId: slot.operationId, snapshotHash: input.snapshotHash,
}
const request = {
phase: 'discussion-writer-recovery', batchId: input.batchId,
slotId: slot.slotId, operationId: slot.operationId, attempt: slot.attempt,
outcomeCertainty: 'unknown_outcome', sideEffectsPossible: true,
}
const gate = await this.requestHumanGate({
type: 'retry', runId: controller.runId, agentRunId: slot.agentRunId,
agentKind: slot.agentKind,
summary: 'The interrupted discussion writer may already have changed the workspace.',
options: [
{ optionId: 'retry-once', name: 'Retry once', kind: 'allow_once' },
{ optionId: 'cancel-retry', name: 'Do not retry', kind: 'reject_once' },
],
request,
}, {
signal: controller.signal,
preserveOnAbort: () => controller.stopReason === 'shutdown',
continuation: { resumeKind: 'agent_slot', ...binding },
})
if (gate.status !== 'approved') {
if (this.completeHumanGateContinuation?.(controller.runId, gate.gateId, 'cancelled') !== true
&& this.hasRunLedger()) throw new Error('LOCAL_RUN_PERSIST_FAILED')
controller.stopReason = 'human_gate_rejected'
return 'stopped'
}
if (gate.optionId !== 'retry-once') throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
input.resumedGate = { ...binding, gateId: gate.gateId, type: 'retry',
status: gate.status, optionId: gate.optionId, request, used: false }
}
}
if (input.resumedGate?.type === 'retry'
&& input.resumedGate.request?.phase === 'discussion-writer-recovery') {
const gate = input.resumedGate
const slot = input.slots.find(item => item.slotId === gate.slotId)
if (gate.status !== 'approved' || gate.optionId !== 'retry-once'
|| !slot || slot.agentKind !== gate.agentKind
|| slot.operationId !== gate.operationId
|| slot.snapshotHash !== gate.snapshotHash
|| slot.attempt !== gate.request.attempt) throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
if (this.completeHumanGateContinuation?.(controller.runId, gate.gateId, 'completed') !== true
&& this.hasRunLedger()) throw new Error('LOCAL_RUN_PERSIST_FAILED')
}
const style = controller.orchestration?.discussionStyle
|| controller.discussionStyle
|| 'sequential'
return style === 'agent-led'
? this.runV4AgentLedDiscussion(group, controller, threadRootId, context, input)
: this.runV4SequentialDiscussion(group, controller, threadRootId, context, input)
},
}
module.exports = { naturalDiscussionMethods }
@@ -0,0 +1,443 @@
// Failed, interrupted and unauthorized invocations: retry policy, pausing an Agent for the run, and recovery prompts.
const { createHash } = require('node:crypto')
const {
MAX_RUN_AGENT_ATTEMPTS,
boundedBackoffDelay,
normalizeAttemptHistoryEntry,
normalizeFailure,
normalizeFailureOutcome,
retryDecision,
} = require('../runs/failure-policy.cjs')
const {
circuitBreakerError,
sanitizedAuthenticationError,
unauthorizedFailure,
} = require('./local-workspace-auto-runner-support.cjs')
const recoveryMethods = {
setCurrentAgent(controller, kind) {
controller.currentKind = kind
controller.progress = []
this.emitChanged()
},
pauseFailedAgentForRun(group, controller, kind, threadRootId, error = null) {
// Failures are scoped to the current run. Group membership is user
// configuration: editing it after a provider or login hiccup made the
// Agent disappear from every later conversation turn, and removing the
// last member made the whole group (and its history) unloadable.
if (group.conversationType === 'direct' || !group.agentKinds.includes(kind)) return false
if (!controller.failedKinds.includes(kind)) controller.failedKinds.push(kind)
const alreadyReported = this.state().messages.some(message => (
message.threadRootId === threadRootId
&& message.agentKind === kind
&& message.system?.key === 'system.agentPausedAfterFailure'
&& message.system?.params?.runId === controller.runId
))
if (!alreadyReported) {
const agent = this.agentLabel(kind)
const reason = normalizeFailure(error).code || 'LOCAL_AGENT_PROCESS_FAILED'
this.addMessage(
group.id,
'system',
`${agent} is paused for the rest of this discussion (${reason}). It stays in the group and will be tried again on the next message.`,
kind,
threadRootId,
{
key: 'system.agentPausedAfterFailure',
params: { agent, reason, runId: controller.runId },
},
)
}
return true
},
shouldPauseFailedAgent(error) {
const category = normalizeFailure(error).category
// Runtime, provider, rate-limit, protocol, and timeout failures stay
// silent run-scoped exclusions; their own failure message explains them.
// Authentication and compatibility failures get an explicit notice
// because the user usually has to act (sign in or update the CLI).
return ['authentication', 'compatibility'].includes(category)
},
replacementInstruction(failedKind) {
return [
`You are replacing ${this.agentLabel(failedKind)} for this turn.`,
'Complete the interrupted Agent slot using the shared task context, then return your own conclusion.',
'Do not claim that the interrupted Agent completed this work.',
].join('\n')
},
retryDelay(failedAttempt) {
return boundedBackoffDelay(failedAttempt, {
baseDelayMs: this.retryBaseDelayMs,
maxDelayMs: this.retryMaxDelayMs,
})
},
operationIdFor(controller, kind, mode) {
const digest = createHash('sha256').update(JSON.stringify({
runId: controller.runId,
kind,
mode,
round: controller.currentRound || 0,
})).digest('hex')
return `agent-operation-${digest}`
},
recordAttempt(group, controller, input) {
const history = Array.isArray(controller.attemptHistory) ? controller.attemptHistory : []
const previousSequence = history.at(-1)?.sequence || 0
const entry = normalizeAttemptHistoryEntry({
sequence: previousSequence + 1,
agentKind: input.agentKind,
phase: input.phase,
attempt: input.attempt,
failureCategory: input.failureCategory ?? null,
policyAction: input.policyAction,
backoffMs: input.backoffMs || 0,
recoveryAgentKind: input.recoveryAgentKind || '',
finalOutcome: input.finalOutcome,
timestamp: Date.now(),
...(input.outcomeCertainty ? { outcomeCertainty: input.outcomeCertainty } : {}),
...(Object.hasOwn(input, 'sideEffectsPossible')
? { sideEffectsPossible: input.sideEffectsPossible }
: {}),
...(input.operationId ? { operationId: input.operationId } : {}),
...(input.idempotencyMode ? { idempotencyMode: input.idempotencyMode } : {}),
})
if (!entry) throw new Error('LOCAL_RUN_ATTEMPT_INVALID')
controller.attemptHistory = [...history, entry].slice(-256)
const persisted = this.checkpointRun?.(group.id, controller)
if (this.hasRunLedger() && persisted !== true) {
throw new Error('LOCAL_RUN_PERSIST_FAILED')
}
return entry
},
async prepareRetry(group, kind, controller, failedAttempt, options = {}) {
if (options.resetSession === true) {
this.resetAgentSession(group, kind, true, controller.taskId)
}
this.markRuntimeCredential(kind, 'unknown')
try { await this.refreshAgents() } catch {
if (controller.signal.aborted) throw new Error('LOCAL_AGENT_EXECUTION_STOPPED')
}
const delayMs = Number.isFinite(options.delayMs)
? Math.max(0, Math.floor(options.delayMs))
: this.retryDelay(failedAttempt)
await this.sleep(delayMs, controller.signal)
return delayMs
},
async invokeTransiently(invokeSource, group, kind, controller, options = {}) {
const operationId = options.operationId
const idempotencyMode = options.idempotencyMode === 'durable' ? 'durable' : 'none'
const sideEffectsPossible = options.sideEffectsPossible === true
for (let attempt = 1; ; attempt += 1) {
if (controller.attemptHistory.length >= MAX_RUN_AGENT_ATTEMPTS) {
throw circuitBreakerError()
}
const phase = attempt === 1 ? (options.phase || 'initial') : 'transient_retry'
const phaseAttempt = attempt === 1 ? (options.attempt || 1) : attempt - 1
try {
const result = await invokeSource()
this.recordAttempt(group, controller, {
agentKind: options.agentKind || kind,
phase,
attempt: phaseAttempt,
failureCategory: null,
policyAction: options.successAction || 'complete',
backoffMs: options.successBackoffMs || 0,
recoveryAgentKind: options.recoveryAgentKind || '',
finalOutcome: 'succeeded',
sideEffectsPossible,
operationId,
idempotencyMode,
})
return result
} catch (error) {
if (controller.signal.aborted) {
this.recordAttempt(group, controller, {
agentKind: options.agentKind || kind,
phase,
attempt: phaseAttempt,
failureCategory: 'cancellation',
policyAction: 'cancel',
recoveryAgentKind: options.recoveryAgentKind || '',
finalOutcome: 'cancelled',
})
throw error
}
if (unauthorizedFailure(error)) throw error
const failure = normalizeFailure(error)
const safety = normalizeFailureOutcome(error, {
category: failure.category,
sideEffectsPossible,
operationId,
idempotencyMode,
})
if (failure.category === 'cancellation') throw error
if (options.deferFailurePolicy === true) {
this.recordAttempt(group, controller, {
agentKind: options.agentKind || kind,
phase,
attempt: phaseAttempt,
failureCategory: failure.category,
policyAction: 'fail',
backoffMs: 0,
recoveryAgentKind: '',
finalOutcome: 'failed',
...safety,
})
throw error
}
// Session recovery is owned by LocalWorkspaceAgentInvocation so a stale
// native Session gets exactly one fresh attempt across every run mode.
// An ambiguous refusal (HTTP 403, proxy or region block) gets exactly
// one more attempt: a transient gateway refusal recovers, while a real
// permission problem fails fast without signing the Agent out.
const maxAttempts = failure.category === 'timeout' && options.retryTimeouts !== true
? 1
: failure.code === 'LOCAL_AGENT_ACCESS_DENIED' ? 2 : 4
const decision = retryDecision(failure, {
attempt,
maxAttempts,
baseDelayMs: this.retryBaseDelayMs,
maxDelayMs: this.retryMaxDelayMs,
rateLimitMinDelayMs: this.retryRateLimitMinDelayMs,
safety,
})
if (decision.action === 'human_gate') {
this.recordAttempt(group, controller, {
agentKind: options.agentKind || kind,
phase,
attempt: phaseAttempt,
failureCategory: failure.category,
policyAction: 'human_gate',
backoffMs: decision.delayMs,
recoveryAgentKind: options.recoveryAgentKind || '',
finalOutcome: 'failed',
...safety,
})
if (!this.requestHumanGate || !error?.runTrace?.agentRunId) {
throw new Error('LOCAL_RUN_RETRY_GATE_INVALID')
}
const gate = await this.requestHumanGate({
type: 'retry',
runId: controller.runId,
agentRunId: error.runTrace.agentRunId,
agentKind: options.agentKind || kind,
summary: 'The previous write-capable Agent attempt may already have changed the workspace.',
options: [
{ optionId: 'retry-once', name: 'Retry once', kind: 'allow_once' },
{ optionId: 'cancel-retry', name: 'Do not retry', kind: 'reject_once' },
],
request: {
failureCategory: failure.category,
outcomeCertainty: safety.outcomeCertainty,
sideEffectsPossible: safety.sideEffectsPossible,
operationId: safety.operationId,
idempotencyMode: safety.idempotencyMode,
},
}, {
signal: controller.signal,
preserveOnAbort: () => controller.stopReason === 'shutdown',
continuation: {
resumeKind: 'agent_slot',
agentRunId: error.runTrace.agentRunId,
agentKind: options.agentKind || kind,
round: controller.currentRound || 0,
...(options.v4AgentSlotBinding || {}),
},
})
if (gate.status !== 'approved') {
this.completeHumanGateContinuation?.(controller.runId, gate.gateId, 'cancelled')
controller.stopReason = 'human_gate_rejected'
controller.abort()
throw new Error('LOCAL_AGENT_EXECUTION_STOPPED')
}
if (this.completeHumanGateContinuation?.(
controller.runId, gate.gateId, 'completed',
) !== true && this.hasRunLedger()) {
throw new Error('LOCAL_RUN_PERSIST_FAILED')
}
this.markRuntimeCredential(kind, 'unknown')
try { await this.refreshAgents() } catch {
if (controller.signal.aborted) throw new Error('LOCAL_AGENT_EXECUTION_STOPPED')
}
await this.sleep(decision.delayMs, controller.signal)
continue
}
if (decision.action !== 'retry') {
this.recordAttempt(group, controller, {
agentKind: options.agentKind || kind,
phase,
attempt: phaseAttempt,
failureCategory: failure.category,
policyAction: options.failureAction
|| (decision.action === 'refresh_session' ? 'refresh_session' : 'fail'),
backoffMs: options.failureBackoffMs || 0,
recoveryAgentKind: options.recoveryAgentKind || '',
finalOutcome: 'failed',
...safety,
})
throw error
}
this.recordAttempt(group, controller, {
agentKind: options.agentKind || kind,
phase,
attempt: phaseAttempt,
failureCategory: failure.category,
policyAction: 'retry',
backoffMs: decision.delayMs,
recoveryAgentKind: options.recoveryAgentKind || '',
finalOutcome: 'failed',
...safety,
})
// Transient retries keep the Agent's readiness untouched. Refreshing
// the whole catalog here re-ran discovery and credential probes while
// the Agent was busy and could flip a healthy Agent to unavailable.
await this.sleep(decision.delayMs, controller.signal)
}
}
},
async controlAfterFailure(group, kind, controller, error) {
const request = this.consumeAgentControl(controller, kind)
if (!request) return null
const failure = normalizeFailure(error)
const manualAttempt = (controller.manualRetryCounts.get(kind) || 0) + 1
if (request.action === 'retry') {
controller.manualRetryCounts.set(kind, manualAttempt)
const delayMs = this.retryDelay(manualAttempt)
this.recordAttempt(group, controller, {
agentKind: kind,
phase: 'manual_retry',
attempt: manualAttempt,
failureCategory: failure.category,
policyAction: 'retry',
backoffMs: delayMs,
finalOutcome: 'failed',
})
await this.prepareRetry(group, kind, controller, manualAttempt, {
resetSession: true,
delayMs,
})
return { action: 'retry' }
}
this.recordAttempt(group, controller, {
agentKind: kind,
phase: 'manual_retry',
attempt: manualAttempt,
failureCategory: failure.category,
policyAction: request.action === 'replace' ? 'replace_agent' : 'cancel',
recoveryAgentKind: request.replacementKind || '',
finalOutcome: request.action === 'replace' ? 'replaced' : 'cancelled',
})
return request
},
async invokeWithUnauthorizedRecovery(input) {
const { controller, kind } = input
if (controller.attemptHistory.length >= MAX_RUN_AGENT_ATTEMPTS) {
throw circuitBreakerError()
}
if (controller.agentSlotKinds.has(kind)) {
throw new Error('LOCAL_AGENT_ATTEMPT_RUNNING')
}
controller.agentSlotKinds.add(kind)
try {
return await this.invokeWithUnauthorizedRecoveryInSlot(input)
} finally {
controller.agentSlotKinds.delete(kind)
}
},
async invokeWithUnauthorizedRecoveryInSlot({
group,
kind,
controller,
threadRootId,
context,
mode = 'auto',
}) {
const requestedOperationId = String(context?.operationId || '')
const operationId = /^[A-Za-z0-9._:-]{1,120}$/.test(requestedOperationId)
? requestedOperationId
: this.operationIdFor(controller, kind, mode)
const contract = this.retryContract(kind) || {}
const idempotencyMode = contract.idempotencyMode === 'durable' ? 'durable' : 'none'
const sideEffectsPossible = group.allowWrite === true
&& context.permissionMode !== 'read-only'
const invokeSource = async () => {
if (context.parallelGraph !== true) this.setCurrentAgent(controller, kind)
return this.invokeAgent(group, kind, mode, controller.signal, threadRootId, {
...context,
yolo: context.yolo ?? this.defaultYolo,
deferCredentialFailure: true,
operationId,
})
}
let phase = 'initial'
let phaseAttempt = 1
while (true) {
try {
return {
result: await this.invokeTransiently(invokeSource, group, kind, controller, {
phase,
attempt: phaseAttempt,
operationId,
idempotencyMode,
sideEffectsPossible,
deferFailurePolicy: context.v4SynthesisRecovery === true
|| (context.v4 === true && context.resumedGate?.type === 'permission'),
// A timed-out automatic slot is a failed participant for this
// round. Retrying it serially delays every healthy peer and
// defeats failure isolation; manual/direct invocations retain
// their existing timeout retry policy.
retryTimeouts: mode !== 'auto',
v4AgentSlotBinding: mode === 'auto' && context.v4 === true ? {
phase: context.phase,
slotId: context.slotId,
operationId,
snapshotHash: context.snapshotHash,
} : null,
}),
removed: false,
}
} catch (error) {
if (controller.signal.aborted) throw error
const control = await this.controlAfterFailure(group, kind, controller, error)
if (control?.action === 'retry') {
phase = 'manual_retry'
phaseAttempt = controller.manualRetryCounts.get(kind) || 1
continue
}
if (control) return { result: null, removed: false, control, error }
if (!unauthorizedFailure(error)) throw error
// Only explicit sign-in failures reach this point. Mark the Agent as
// needing a fresh login; the catalog clears it on the next refresh
// after the retry window or as soon as native credentials verify.
this.markRuntimeCredential(kind, 'missing')
this.recordAttempt(group, controller, {
agentKind: kind,
phase,
attempt: phaseAttempt,
failureCategory: 'authentication',
policyAction: 'fail',
finalOutcome: 'failed',
...normalizeFailureOutcome(error, {
sideEffectsPossible: group.allowWrite === true,
operationId,
idempotencyMode,
}),
})
throw sanitizedAuthenticationError(error)
}
}
},
}
module.exports = { recoveryMethods }
@@ -0,0 +1,313 @@
// V2 round-based runs. Kept so older runs can be resumed; new runs do not use it.
const { MAX_RUN_AGENT_ATTEMPTS } = require('../runs/failure-policy.cjs')
const {
circuitBreakerError,
circuitBreakerFailure,
hardBudgetFailure,
terminalRunStatusForReason,
} = require('./local-workspace-auto-runner-support.cjs')
const roundsMethods = {
async runRounds(group, controller, threadRootId, maxRounds, context, resume = false) {
const {
rootAttachments,
rootSkillsByKind,
rootKnowledgeBasesByKind,
} = context
const cursor = resume ? controller.orchestration : null
const attachmentRecipients = new Set(cursor?.attachmentRecipients || [])
let terminalFailureOccurred = cursor?.terminalFailureOccurred === true
let activeKinds = cursor ? [...cursor.activeKinds] : [...controller.targetKinds]
let executionSequence = Number(cursor?.executionSequence) || 0
let totalSuccesses = cursor?.totalSuccesses || 0
let consensusReached = false
const reportedFailures = new Set()
const firstRound = resume ? Math.max(0, controller.currentRound - 1) : 0
for (
let round = firstRound;
(controller.unlimitedRounds || round < maxRounds) && !controller.signal.aborted;
round += 1
) {
const resumedRound = resume && round === firstRound
const successfulKinds = new Set(resumedRound ? cursor.successfulKinds : [])
const agreementKinds = new Set(resumedRound ? cursor.agreementKinds : [])
const replacementInstructions = new Map()
const roundQueue = resumedRound ? [...cursor.pendingKinds] : [...activeKinds]
controller.currentRound = round + 1
controller.completedKinds = resumedRound
? activeKinds.filter(kind => !roundQueue.includes(kind))
: []
controller.failedKinds = resumedRound
? controller.completedKinds.filter(kind => !successfulKinds.has(kind))
: []
if (!resumedRound) {
this.checkpointOrchestration(group, controller, {
currentKind: '',
pendingKinds: [...roundQueue],
activeKinds: [...activeKinds],
successfulKinds: [],
agreementKinds: [],
attachmentRecipients: [...attachmentRecipients],
totalSuccesses,
terminalFailureOccurred,
executionSequence,
})
}
this.emitChanged()
while (roundQueue.length) {
const kind = roundQueue.shift()
if (!activeKinds.includes(kind)) {
this.checkpointOrchestration(group, controller, { pendingKinds: [...roundQueue] })
continue
}
if (controller.signal.aborted) break
const executionKind = kind
executionSequence += 1
this.setCurrentAgent(controller, executionKind)
this.checkpointOrchestration(group, controller, {
currentKind: executionKind,
pendingKinds: [...roundQueue],
activeKinds: [...activeKinds],
successfulKinds: [...successfulKinds],
agreementKinds: [...agreementKinds],
attachmentRecipients: [...attachmentRecipients],
totalSuccesses,
terminalFailureOccurred,
executionSequence,
})
try {
if (controller.attemptHistory.length >= MAX_RUN_AGENT_ATTEMPTS) {
throw circuitBreakerError()
}
const collaborationPackage = this.prepareCollaborationPackage(
group, controller, activeKinds, executionKind, threadRootId,
)
const attachments = attachmentRecipients.has(executionKind)
? []
: rootAttachments.map(attachment => attachment.path)
const invocation = await this.invokeWithUnauthorizedRecovery({
group,
kind: executionKind,
controller,
activeKinds,
threadRootId,
context: {
attachments,
attachmentSnapshots: attachments.length ? rootAttachments : [],
skillHints: rootSkillsByKind.get(executionKind) || [],
knowledgeBaseHints: rootKnowledgeBasesByKind.get(executionKind) || [],
runtimeInstruction: replacementInstructions.get(executionKind) || '',
collaborationPackage,
contextOptions: {
focusUserMessageId: threadRootId,
omitAgentThreadRootId: threadRootId,
},
executionSequence,
},
reportedFailures,
})
replacementInstructions.delete(executionKind)
if (invocation.control?.action === 'replace') {
this.recordAgentInterruption(
group.id, executionKind, invocation.error, threadRootId, 'stopped', reportedFailures,
)
if (!controller.failedKinds.includes(executionKind)) {
controller.failedKinds.push(executionKind)
}
if (!controller.completedKinds.includes(executionKind)) {
controller.completedKinds.push(executionKind)
}
successfulKinds.delete(executionKind)
agreementKinds.delete(executionKind)
const replacementKind = invocation.control.replacementKind
activeKinds = activeKinds.filter(activeKind => activeKind !== executionKind)
replacementInstructions.set(
replacementKind,
this.replacementInstruction(executionKind),
)
if (!roundQueue.includes(replacementKind)) roundQueue.unshift(replacementKind)
controller.currentKind = ''
controller.progress = []
this.checkpointOrchestration(group, controller, {
currentKind: '',
pendingKinds: [...roundQueue],
activeKinds: [...activeKinds],
successfulKinds: [...successfulKinds],
agreementKinds: [...agreementKinds],
})
this.emitChanged()
continue
}
if (invocation.control?.action === 'cancel') {
this.recordAgentInterruption(
group.id, executionKind, invocation.error, threadRootId, 'stopped', reportedFailures,
)
if (!controller.failedKinds.includes(executionKind)) {
controller.failedKinds.push(executionKind)
}
if (!controller.completedKinds.includes(executionKind)) {
controller.completedKinds.push(executionKind)
}
successfulKinds.delete(executionKind)
agreementKinds.delete(executionKind)
controller.currentKind = ''
controller.progress = []
this.checkpointOrchestration(group, controller, {
currentKind: '',
pendingKinds: [...roundQueue],
successfulKinds: [...successfulKinds],
agreementKinds: [...agreementKinds],
})
this.emitChanged()
continue
}
const result = invocation.result
if (attachments.length) attachmentRecipients.add(executionKind)
successfulKinds.add(executionKind)
if (result.consensus) agreementKinds.add(executionKind)
else agreementKinds.delete(executionKind)
this.recordCollaborationResult(
group, controller, activeKinds, executionKind, result,
)
} catch (error) {
if (circuitBreakerFailure(error)) {
controller.stopReason = 'circuit_breaker'
controller.abort()
break
}
const hardBudget = hardBudgetFailure(error)
if (hardBudget) {
this.recordAgentFailure(
group.id, executionKind, error, threadRootId, reportedFailures,
)
successfulKinds.delete(executionKind)
agreementKinds.delete(executionKind)
if (!controller.failedKinds.includes(executionKind)) {
controller.failedKinds.push(executionKind)
}
if (!controller.completedKinds.includes(executionKind)) {
controller.completedKinds.push(executionKind)
}
terminalFailureOccurred = true
controller.stopReason = 'hard_budget'
controller.abort()
break
}
if (controller.signal.aborted) {
const interruptedKind = controller.currentKind || executionKind
if (error?.runTrace) {
this.recordAgentInterruption(
group.id,
interruptedKind,
error,
threadRootId,
controller.stopReason === 'shutdown' ? 'interrupted' : 'stopped',
reportedFailures,
)
if (!controller.completedKinds.includes(interruptedKind)) {
controller.completedKinds.push(interruptedKind)
}
controller.currentKind = ''
controller.progress = []
this.emitChanged()
}
break
}
this.recordAgentFailure(
group.id, executionKind, error, threadRootId, reportedFailures,
)
successfulKinds.delete(executionKind)
agreementKinds.delete(executionKind)
if (!controller.failedKinds.includes(executionKind)) {
controller.failedKinds.push(executionKind)
}
terminalFailureOccurred = true
// Failed CLIs leave only this run's active queue; the configured
// group keeps them so the next turn can retry.
if (this.shouldPauseFailedAgent(error)) {
this.pauseFailedAgentForRun(group, controller, executionKind, threadRootId, error)
}
activeKinds = activeKinds.filter(activeKind => activeKind !== executionKind)
}
if (controller.signal.aborted) break
if (!controller.completedKinds.includes(executionKind)) {
controller.completedKinds.push(executionKind)
}
controller.currentKind = ''
controller.progress = []
this.checkpointOrchestration(group, controller, {
currentKind: '',
pendingKinds: [...roundQueue],
activeKinds: [...activeKinds],
successfulKinds: [...successfulKinds],
agreementKinds: [...agreementKinds],
attachmentRecipients: [...attachmentRecipients],
totalSuccesses,
terminalFailureOccurred,
executionSequence,
})
this.emitChanged()
}
if (controller.signal.aborted) break
const successes = activeKinds.filter(kind => successfulKinds.has(kind)).length
const agreements = activeKinds.filter(kind => agreementKinds.has(kind)).length
totalSuccesses += successes
this.checkpointOrchestration(group, controller, {
currentKind: '',
pendingKinds: [],
activeKinds: [...activeKinds],
successfulKinds: [...successfulKinds],
agreementKinds: [...agreementKinds],
attachmentRecipients: [...attachmentRecipients],
totalSuccesses,
terminalFailureOccurred,
executionSequence,
})
if (activeKinds.length < 2) break
const crossReviewComplete = !controller.unlimitedRounds || controller.currentRound >= 2
if (crossReviewComplete
&& successes === activeKinds.length
&& agreements === activeKinds.length) {
consensusReached = true
break
}
}
let runStatus
if (controller.signal.aborted) {
runStatus = terminalRunStatusForReason(controller.stopReason)
} else if (terminalFailureOccurred) runStatus = totalSuccesses > 0 ? 'partial' : 'failed'
else if (consensusReached) runStatus = 'completed'
else runStatus = totalSuccesses > 0 ? 'round-limit' : 'failed'
if (!terminalFailureOccurred && !controller.unlimitedRounds
&& (runStatus === 'round-limit' || runStatus === 'failed')) {
this.addMessage(
group.id,
'system',
`Automatic discussion reached its ${maxRounds}-round limit. The latest replies above are the current result.`,
'',
threadRootId,
{ key: 'system.autoRoundLimit', params: { rounds: maxRounds } },
)
}
if (controller.stopReason === 'circuit_breaker') {
this.addMessage(
group.id,
'system',
`Automatic discussion stopped after ${MAX_RUN_AGENT_ATTEMPTS} Agent attempts.`,
'',
threadRootId,
{
key: 'system.runCircuitBreaker',
params: { maxAttempts: MAX_RUN_AGENT_ATTEMPTS },
},
)
}
return runStatus
},
}
module.exports = { roundsMethods }
@@ -0,0 +1,419 @@
// V3 task-graph runs. Kept so older runs can be resumed; new runs do not use it.
const { createHash } = require('node:crypto')
const {
appendBlackboardEntry,
emptyCollaborationState,
publicCollaborationText,
} = require('../collaboration/collaboration-records.cjs')
const { unlimitedReviewContract } = require('./local-workspace-context.cjs')
const {
parseTaskGraphCursor,
readyTaskGraphNodes,
terminalTaskGraphState,
updateTaskGraphCursor,
} = require('../collaboration/task-graph-records.cjs')
const {
circuitBreakerFailure,
hardBudgetFailure,
terminalRunStatusForReason,
} = require('./local-workspace-auto-runner-support.cjs')
const taskGraphMethods = {
unresolvedTaskGraphConflicts(collaborationInput = null) {
const collaboration = collaborationInput || emptyCollaborationState()
const resolved = new Set(collaboration.entries
.filter(entry => entry.entryType === 'decision' && entry.lifecycle.state === 'active')
.flatMap(entry => entry.refs))
return collaboration.entries.filter(entry => (
entry.entryType === 'conflict'
&& entry.lifecycle.state === 'active'
&& !resolved.has(entry.entryId)
))
},
taskGraphAcceptanceCriteria(node) {
const criteria = []
if (node.acceptance.requireConclusion) criteria.push('Return one concrete conclusion.')
if (node.acceptance.minArtifactRefs) {
criteria.push(`Produce at least ${node.acceptance.minArtifactRefs} durable Artifact reference(s).`)
}
if (node.acceptance.minEvidenceRefs) {
criteria.push(`Produce at least ${node.acceptance.minEvidenceRefs} durable Evidence reference(s).`)
}
return criteria.length ? criteria : ['Complete the explicit Human decision.']
},
taskGraphSelectedEntryIds(controller, node) {
const cursor = parseTaskGraphCursor(controller.orchestration.taskGraph)
const states = new Map(cursor.nodeStates.map(state => [state.nodeId, state]))
const selected = node.inputNodeIds.flatMap(nodeId => states.get(nodeId)?.entryIds || [])
if (['reviewer', 'arbiter'].includes(node.role)) {
selected.push(...this.unresolvedTaskGraphConflicts(
controller.orchestration.collaboration,
).map(entry => entry.entryId))
}
return [...new Set(selected)]
},
taskGraphPrompt(node, objective, collaborationPackage, unlimitedRounds = false) {
return [
'MELDWORK_TASK_GRAPH_V1',
`Node: ${node.nodeId}`,
`Role: ${node.role}`,
`Objective: ${objective}`,
`Expected output: ${node.expectedOutput}`,
`Acceptance criteria: ${this.taskGraphAcceptanceCriteria(node).join(' | ')}`,
unlimitedReviewContract(unlimitedRounds),
collaborationPackage.text,
'Return the requested result directly. Completion is evaluated from durable typed records; do not emit a consensus marker.',
].join('\n')
},
checkpointTaskGraph(group, controller, cursorInput, options = {}) {
let taskGraph = parseTaskGraphCursor(cursorInput)
taskGraph = parseTaskGraphCursor({
...taskGraph,
terminalState: terminalTaskGraphState(taskGraph),
})
const states = new Map(taskGraph.nodeStates.map(state => [state.nodeId, state]))
const pendingKinds = taskGraph.graph.nodes
.filter(node => states.get(node.nodeId)?.status === 'pending' && node.agentKind)
.map(node => node.agentKind)
const successfulKinds = taskGraph.graph.nodes
.filter(node => states.get(node.nodeId)?.status === 'accepted' && node.agentKind)
.map(node => node.agentKind)
const uniquePendingKinds = [...new Set(pendingKinds)]
const uniqueSuccessfulKinds = [...new Set(successfulKinds)]
this.checkpointOrchestration(group, controller, {
taskGraph,
currentKind: options.currentKind || '',
pendingKinds: uniquePendingKinds,
successfulKinds: uniqueSuccessfulKinds,
agreementKinds: [],
totalSuccesses: taskGraph.nodeStates.filter(state => state.status === 'accepted').length,
terminalFailureOccurred: taskGraph.terminalState === 'failed',
})
return taskGraph
},
taskGraphResultAccepted(node, result) {
const conclusion = publicCollaborationText(result?.message?.content || '')
const artifactIds = result?.outcomeRefs?.artifactIds || []
const evidenceIds = result?.outcomeRefs?.evidenceIds || []
return (!node.acceptance.requireConclusion || Boolean(conclusion))
&& artifactIds.length >= node.acceptance.minArtifactRefs
&& evidenceIds.length >= node.acceptance.minEvidenceRefs
},
applyTaskGraphAgentResult(group, controller, node, result) {
const conflicts = this.unresolvedTaskGraphConflicts(controller.orchestration.collaboration)
const entryIds = this.recordCollaborationResult(
group,
controller,
controller.orchestration.activeKinds,
node.agentKind,
result,
{
role: node.role,
entryType: node.role === 'arbiter' ? 'decision' : 'claim',
subject: node.role === 'primary'
? `task:${controller.taskId}:conclusion`
: `task:${controller.taskId}:node:${node.nodeId}`,
refs: node.role === 'arbiter' ? conflicts.map(entry => entry.entryId) : [],
},
)
const accepted = this.taskGraphResultAccepted(node, result)
const conclusion = publicCollaborationText(result?.message?.content || '')
const artifactIds = result?.outcomeRefs?.artifactIds || []
const evidenceIds = result?.outcomeRefs?.evidenceIds || []
let cursor = updateTaskGraphCursor(
controller.orchestration.taskGraph,
node.nodeId,
{
status: accepted ? 'accepted' : 'failed',
entryIds,
artifactIds,
evidenceIds,
conclusionHash: conclusion
? createHash('sha256').update(conclusion).digest('hex')
: '',
},
)
if (accepted) {
if (!controller.completedKinds.includes(node.agentKind)) {
controller.completedKinds.push(node.agentKind)
}
} else if (!controller.failedKinds.includes(node.agentKind)) {
controller.failedKinds.push(node.agentKind)
}
cursor = this.checkpointTaskGraph(group, controller, cursor)
return accepted
},
taskGraphDecisionAgentRunId(graphId, nodeId) {
const digest = createHash('sha256').update(JSON.stringify([graphId, nodeId])).digest('hex')
return `graph-decision-${digest}`
},
taskGraphDecisionAnchor(controller, node) {
const graph = parseTaskGraphCursor(controller.orchestration.taskGraph)
const byId = new Map(graph.graph.nodes.map(candidate => [candidate.nodeId, candidate]))
return [...node.dependsOn].reverse()
.map(nodeId => byId.get(nodeId)?.agentKind)
.find(Boolean) || controller.targetKinds[0]
},
applyTaskGraphHumanDecision(group, controller, node, decision) {
const option = node.decisionOptions.find(candidate => candidate.optionId === decision.optionId)
if (!option) throw new Error('LOCAL_WORKFLOW_DECISION_INVALID')
const accepted = decision.status === 'approved'
const conflicts = this.unresolvedTaskGraphConflicts(controller.orchestration.collaboration)
let collaboration = controller.orchestration.collaboration
collaboration = appendBlackboardEntry(collaboration, {
entryType: 'decision',
subject: `task:${controller.taskId}:node:${node.nodeId}`,
statement: `Human selected: ${option.name}`,
value: '',
owner: { type: 'harness' },
audience: { roles: ['primary', 'reviewer', 'arbiter'], agentKinds: [] },
lifecycle: {
state: 'active',
sequence: collaboration.entries.length + 1,
recordedAt: Date.now(),
supersedesEntryId: null,
},
provenance: {
runId: controller.runId,
taskId: controller.taskId,
round: controller.currentRound,
agentRunId: this.taskGraphDecisionAgentRunId(
controller.orchestration.taskGraph.graph.graphId,
node.nodeId,
),
artifactIds: [],
evidenceIds: [],
},
refs: conflicts.map(entry => entry.entryId),
})
const decisionEntryId = collaboration.entries.at(-1).entryId
this.checkpointOrchestration(group, controller, { collaboration })
let cursor = updateTaskGraphCursor(
controller.orchestration.taskGraph,
node.nodeId,
{
status: accepted ? 'accepted' : 'rejected',
entryIds: [decisionEntryId],
decisionOptionId: option.optionId,
},
)
cursor = this.checkpointTaskGraph(group, controller, cursor)
return accepted
},
async executeTaskGraphAgentNode(
group, controller, threadRootId, context, node, parallelGraph = false,
) {
const selectedEntryIds = this.taskGraphSelectedEntryIds(controller, node)
const collaborationPackage = this.prepareCollaborationPackage(
group,
controller,
controller.orchestration.activeKinds,
node.agentKind,
threadRootId,
{
role: node.role,
selectedEntryIds,
expectedOutput: node.expectedOutput,
acceptanceCriteria: this.taskGraphAcceptanceCriteria(node),
},
)
const isolated = ['reviewer', 'arbiter'].includes(node.role)
const attachments = node.role === 'primary'
&& !controller.orchestration.attachmentRecipients.includes(node.agentKind)
? context.rootAttachments.map(attachment => attachment.path)
: []
const objective = this.collaborationObjective(group, controller, threadRootId)
return this.invokeWithUnauthorizedRecovery({
group,
kind: node.agentKind,
controller,
threadRootId,
context: {
attachments,
attachmentSnapshots: attachments.length ? context.rootAttachments : [],
skillHints: context.rootSkillsByKind.get(node.agentKind) || [],
knowledgeBaseHints: context.rootKnowledgeBasesByKind.get(node.agentKind) || [],
collaborationPackage,
completionPolicy: 'typed',
parallelGraph,
runtimeInstruction: isolated ? '' : [
`Execute task-graph node ${node.nodeId} as ${node.role}.`,
`Expected output: ${node.expectedOutput}`,
'Do not emit a consensus marker.',
].join('\n'),
...(isolated ? {
sessionPolicy: 'isolated',
promptOverride: this.taskGraphPrompt(
node, objective, collaborationPackage, controller.unlimitedRounds === true,
),
contextPackId: controller.contextPackId,
} : {}),
contextOptions: {
focusUserMessageId: threadRootId,
omitAgentThreadRootId: threadRootId,
},
},
mode: 'auto',
})
},
async executeTaskGraphHumanNode(group, controller, node) {
if (!this.requestHumanGate) throw new Error('LOCAL_WORKFLOW_DECISION_UNAVAILABLE')
const anchorKind = this.taskGraphDecisionAnchor(controller, node)
const agentRunId = this.taskGraphDecisionAgentRunId(
controller.orchestration.taskGraph.graph.graphId,
node.nodeId,
)
let cursor = updateTaskGraphCursor(
controller.orchestration.taskGraph,
node.nodeId,
{ status: 'waiting', attention: 'decision', attempts: 1 },
)
cursor = this.checkpointTaskGraph(group, controller, cursor, { currentKind: anchorKind })
const gate = await this.requestHumanGate({
type: 'decision',
runId: controller.runId,
agentRunId,
agentKind: anchorKind,
summary: node.expectedOutput,
options: node.decisionOptions,
request: {
graphId: cursor.graph.graphId,
nodeId: node.nodeId,
inputNodeIds: node.inputNodeIds,
},
}, {
signal: controller.signal,
preserveOnAbort: () => controller.stopReason === 'shutdown',
continuation: {
resumeKind: 'role_review_decision',
agentRunId,
agentKind: anchorKind,
round: controller.currentRound,
},
})
const accepted = this.applyTaskGraphHumanDecision(group, controller, node, gate)
if (this.completeHumanGateContinuation?.(
controller.runId,
gate.gateId,
accepted ? 'completed' : 'cancelled',
) !== true && this.hasRunLedger()) {
throw new Error('LOCAL_RUN_PERSIST_FAILED')
}
return accepted
},
taskGraphBatch(group, readyNodes) {
const parallel = []
const kinds = new Set()
if (group.allowWrite !== true) {
for (const node of readyNodes) {
if (!node.parallel || node.role === 'human' || kinds.has(node.agentKind)) continue
parallel.push(node)
kinds.add(node.agentKind)
}
}
return parallel.length > 1 ? parallel : readyNodes.slice(0, 1)
},
async runTaskGraph(group, controller, threadRootId, context) {
while (!controller.signal.aborted) {
let cursor = parseTaskGraphCursor(controller.orchestration.taskGraph)
const terminalState = terminalTaskGraphState(cursor)
if (terminalState !== 'running') {
cursor = this.checkpointTaskGraph(group, controller, cursor)
break
}
if (!controller.unlimitedRounds && controller.currentRound >= controller.maxRounds) break
const readyNodes = readyTaskGraphNodes(cursor)
if (!readyNodes.length) {
cursor = parseTaskGraphCursor({ ...cursor, terminalState: 'failed' })
this.checkpointTaskGraph(group, controller, cursor)
break
}
const batch = this.taskGraphBatch(group, readyNodes)
controller.currentRound += 1
if (batch.length === 1 && batch[0].role === 'human') {
await this.executeTaskGraphHumanNode(group, controller, batch[0])
continue
}
const conflicts = this.unresolvedTaskGraphConflicts(controller.orchestration.collaboration)
for (const node of batch) {
cursor = updateTaskGraphCursor(cursor, node.nodeId, {
status: 'running',
attention: node.role === 'reviewer' && conflicts.length
? 'review'
: (node.role === 'arbiter' && conflicts.length ? 'decision' : 'none'),
attempts: cursor.nodeStates.find(state => state.nodeId === node.nodeId).attempts + 1,
})
}
const currentKind = batch.length === 1 ? batch[0].agentKind : ''
cursor = this.checkpointTaskGraph(group, controller, cursor, { currentKind })
this.emitChanged()
const settled = await Promise.allSettled(batch.map(node => (
this.executeTaskGraphAgentNode(
group, controller, threadRootId, context, node, batch.length > 1,
)
)))
for (let index = 0; index < batch.length; index += 1) {
const node = batch[index]
const outcome = settled[index]
if (outcome.status === 'fulfilled' && outcome.value?.result) {
if (outcome.value.result.outcomeRefs?.artifactIds?.length
&& !controller.orchestration.attachmentRecipients.includes(node.agentKind)) {
this.checkpointOrchestration(group, controller, {
attachmentRecipients: [...controller.orchestration.attachmentRecipients, node.agentKind],
})
}
this.applyTaskGraphAgentResult(group, controller, node, outcome.value.result)
continue
}
const error = outcome.status === 'rejected'
? outcome.reason
: (outcome.value?.error || new Error('LOCAL_AGENT_UNKNOWN_FAILURE'))
if (controller.signal.aborted) continue
this.recordAgentFailure(group.id, node.agentKind, error, threadRootId, new Set())
cursor = updateTaskGraphCursor(
controller.orchestration.taskGraph,
node.nodeId,
{ status: 'failed' },
)
this.checkpointTaskGraph(group, controller, cursor)
if (!controller.failedKinds.includes(node.agentKind)) {
controller.failedKinds.push(node.agentKind)
}
if (circuitBreakerFailure(error)) {
controller.stopReason = 'circuit_breaker'
controller.abort()
} else if (hardBudgetFailure(error)) {
controller.stopReason = 'hard_budget'
controller.abort()
}
}
controller.currentKind = ''
controller.progress = []
this.emitChanged()
}
if (controller.signal.aborted) return terminalRunStatusForReason(controller.stopReason)
const cursor = parseTaskGraphCursor(controller.orchestration.taskGraph)
if (cursor.terminalState === 'accepted') return 'completed'
if (cursor.terminalState === 'rejected') return 'stopped'
if (cursor.terminalState === 'failed') {
return cursor.nodeStates.some(state => state.status === 'accepted') ? 'partial' : 'failed'
}
return cursor.nodeStates.some(state => state.status === 'accepted') ? 'round-limit' : 'failed'
},
}
module.exports = { taskGraphMethods }
@@ -0,0 +1,403 @@
// What a V4 run freezes and shares: the task snapshot, stored receipts and the collaboration package.
const { createHash } = require('node:crypto')
const { peerCollaborationText, publicCollaborationText } = require('../collaboration/collaboration-records.cjs')
const {
buildCollaborationPackage,
hashValue,
parseCollaborationControlBlock,
resolveCoordinationConsensus,
} = require('../collaboration/orchestration-v4-records.cjs')
const { canonicalJson } = require('../collaboration/context-pack-records.cjs')
const {
restoreV4SnapshotSkills,
v4SnapshotBodyHash,
v4SnapshotSkillHints,
validateV4SnapshotBody,
} = require('./local-workspace-context.cjs')
const v4ContextMethods = {
v4OperationId(
controller, kind, phase, slotId = '', round = controller.currentRound || 0,
) {
const digest = createHash('sha256').update(JSON.stringify([
controller.runId,
controller.taskId,
kind,
slotId,
phase,
round,
])).digest('hex')
return `operation-${digest}`
},
v4SnapshotRecord(controller, snapshot, targetKinds) {
if (!this.contentBlobStore) throw new Error('LOCAL_RUN_SNAPSHOT_STORE_UNAVAILABLE')
const serialized = canonicalJson(snapshot)
const contentRef = this.contentBlobStore.put(serialized, { mediaType: 'application/json' })
const sourceIds = [snapshot?.messageId, ...(snapshot?.history || []).map(item => item.id)]
.filter(Boolean).slice(-64)
const taskId = snapshot?.taskId || controller.taskId || null
const record = {
contextPackId: controller.contextPackId || null,
taskId,
messageId: snapshot?.messageId || controller.threadRootId || null,
groupId: snapshot?.group?.id || controller.groupId || null,
round: Math.max(0, Number(controller.currentRound) || 0),
targetKinds: [...targetKinds],
sourceIds,
capturedAt: Date.now(),
charCount: Buffer.byteLength(serialized),
contentHash: contentRef.hash,
bodyHash: v4SnapshotBodyHash(snapshot),
contentRef,
}
return { record, snapshotHash: hashValue(record) }
},
v4SnapshotSkillHints(snapshot, targetKinds) {
return v4SnapshotSkillHints(snapshot, targetKinds)
},
async v4RestoreSnapshotSkills(snapshot, targetKinds, persisted = null) {
return restoreV4SnapshotSkills({
snapshot,
targetKinds,
validateSkillSelections: this.validateSkillSelections,
persisted,
})
},
v4LoadSnapshot(orchestration, { taskId, messageId, groupId, targetKinds }) {
const record = orchestration?.snapshot
if (!record?.contentRef || !this.contentBlobStore) {
throw new Error('LOCAL_RUN_SNAPSHOT_INVALID')
}
let bytes
try { bytes = this.contentBlobStore.read(record.contentRef) } catch {
throw new Error('LOCAL_RUN_SNAPSHOT_INVALID')
}
const serialized = bytes.toString('utf8')
let body
try { body = JSON.parse(serialized) } catch {
throw new Error('LOCAL_RUN_SNAPSHOT_INVALID')
}
return validateV4SnapshotBody({
body,
serialized,
byteLength: bytes.length,
record,
orchestrationSnapshotHash: orchestration.snapshotHash,
taskId,
messageId,
groupId,
targetKinds,
})
},
v4RestoreReceipts(controller) {
if (Array.isArray(controller.v4ReceiptRecords)) {
return controller.v4ReceiptRecords.map(item => ({
receipt: item.receipt,
verdict: item.verdict || '',
resolvedIssueIds: Array.isArray(item.resolvedIssueIds)
? [...item.resolvedIssueIds]
: [],
}))
}
const restored = []
const slots = Array.isArray(controller.orchestration?.slots)
? controller.orchestration.slots
: []
for (const slot of slots) {
const records = slot?.resultRefs?.workflowOutcomeRefs
if (!Array.isArray(records)) continue
for (const record of records) {
if (!record?.receipt) continue
try {
const receipt = parseCollaborationControlBlock(record.receipt)
restored.push({
receipt,
verdict: typeof record.verdict === 'string' ? record.verdict : '',
resolvedIssueIds: Array.isArray(record.resolvedIssueIds)
? record.resolvedIssueIds.filter(value => typeof value === 'string')
: [],
})
} catch { /* malformed historical receipts are ignored and re-run */ }
}
}
controller.v4ReceiptRecords = restored
return restored
},
v4Package(receiptRecords, options = {}) {
const recipientKind = String(options.recipientKind || '')
const binding = options.sessionBinding || {}
const deliveryState = Array.isArray(options.deliveryState) ? options.deliveryState : []
const latest = new Map()
for (const item of receiptRecords) {
const receipt = item?.receipt
if (!receipt) continue
const key = receipt.agentKind || `${receipt.phase}\u0000${receipt.summary}`
const previous = latest.get(key)
if (!previous || (Number(receipt.deliveryWatermark) || 0)
> (Number(previous.receipt.deliveryWatermark) || 0)
|| ((Number(receipt.deliveryWatermark) || 0)
=== (Number(previous.receipt.deliveryWatermark) || 0)
&& receipt.phase.localeCompare(previous.receipt.phase) > 0)) {
latest.set(key, item)
}
}
let selectedItems = [...latest.values()]
const snapshotHash = typeof options.snapshotHash === 'string' ? options.snapshotHash : ''
const targetKinds = new Set(Array.isArray(options.targetKinds) ? options.targetKinds : [])
const resolvedIssueIds = []
const resolved = new Set()
for (const item of receiptRecords) {
const receipt = item?.receipt
if (!snapshotHash || receipt?.snapshotHash !== snapshotHash
|| (targetKinds.size && !targetKinds.has(receipt.agentKind))) continue
for (const value of Array.isArray(item.resolvedIssueIds) ? item.resolvedIssueIds : []) {
if (typeof value !== 'string' || !value || value.length > 120 || resolved.has(value)) continue
resolved.add(value)
resolvedIssueIds.push(value)
if (resolvedIssueIds.length >= 256) break
}
if (resolvedIssueIds.length >= 256) break
}
if (recipientKind && binding.sessionRefHash && !options.forceFull) {
const acknowledged = new Map(deliveryState
.filter(item => item.recipientKind === recipientKind
&& item.sessionRefHash === binding.sessionRefHash
&& item.sessionProvenanceHash === binding.sessionProvenanceHash
&& item.status === 'acknowledged')
.map(item => [`${item.sourceAgentKind}\u0000${item.sourcePhase}`, item.watermark]))
selectedItems = selectedItems.filter(item => {
const receipt = item.receipt
const previous = acknowledged.get(`${receipt.agentKind}\u0000${receipt.phase}`) || 0
return (Number(receipt.deliveryWatermark) || 0) > previous
})
}
const selectedReceipts = selectedItems.map(item => item.receipt)
const totalLimit = Number.isSafeInteger(options.totalLimit) ? options.totalLimit : 6000
const packageRecord = buildCollaborationPackage(selectedReceipts, {
targetKinds: options.targetKinds || [],
totalLimit,
resolvedIssueIds,
})
const text = this.v4SanitizeDeliveryText(packageRecord.text, packageRecord.text.length)
return text === packageRecord.text
? packageRecord
: { ...packageRecord, text, totalChars: text.length, packageHash: hashValue(text) }
},
v4CoordinationResult({
receiptRecords, targetKinds, snapshotHash, snapshot = null,
challengeBindings = [], requireSupport = true,
}) {
const candidateReceipts = receiptRecords
.filter(record => record?.receipt?.phase === 'challenge')
const supportReceipts = requireSupport
? targetKinds.map((kind) => {
const operationId = challengeBindings.find(binding => (
binding.reviewerKind === kind
))?.reviewerOperationId || ''
return this.v4ReceiptForOperation(
receiptRecords, 'challenge', kind, operationId,
) || null
})
: []
if (supportReceipts.some(receipt => !receipt)) {
const result = resolveCoordinationConsensus({
targetKinds, snapshotHash, candidateReceipts, supportReceipts: [],
})
return this.v4SemanticCoordinationResult(result, snapshot)
}
const result = resolveCoordinationConsensus({
targetKinds, snapshotHash, candidateReceipts, supportReceipts,
})
return this.v4SemanticCoordinationResult(result, snapshot)
},
v4SemanticCoordinationResult(result, snapshot) {
const candidates = result.candidates.filter((candidate) => {
try {
for (const assignment of candidate.assignments) {
this.v4DirectAssignmentReferences(assignment, snapshot)
}
return true
} catch (error) {
if (error?.message === 'LOCAL_RUN_V4_ASSIGNMENT_REFERENCE_INVALID') return false
throw error
}
})
const candidateHashes = new Set(candidates.map(candidate => candidate.planHash))
return {
...result,
plan: result.plan && candidateHashes.has(result.plan.planHash) ? result.plan : null,
candidates,
}
},
v4SanitizeDeliveryText(value, limit = 6000, options = {}) {
const privateWrapper = '(?:private[ _-]?(?:reasoning|thoughts?)|analysis|chain[ _-]?of[ _-]?thought|cot)'
const privateHeading = '(?:private[ _-]?(?:reasoning|thoughts?)|chain[ _-]?of[ _-]?thought|cot)'
const stripped = this.v4StripMeldworkControls(
options.workspaceRoot
? peerCollaborationText(value, { workspaceRoot: options.workspaceRoot })
: publicCollaborationText(value, Number.MAX_SAFE_INTEGER),
)
const maximum = Number.isSafeInteger(limit) && limit >= 0 ? limit : 6000
let privateSectionLevel = null
const withoutPrivateSections = stripped.split(/\r?\n/).filter((line) => {
const heading = line.match(/^\s*(#{1,6})\s+(.+)$/u)
const level = heading?.[1].length
if (heading && new RegExp(`^${privateHeading}\\b`, 'iu').test(heading[2].trim())) {
privateSectionLevel = Math.min(privateSectionLevel ?? level, level)
return false
}
if (privateSectionLevel !== null && heading && level <= privateSectionLevel) {
privateSectionLevel = null
}
return privateSectionLevel === null
}).join('\n')
return withoutPrivateSections
.replace(new RegExp(`<\\s*${privateWrapper}\\b[^>]*>[\\s\\S]*?<\\/\\s*${privateWrapper}\\s*>`, 'giu'), '')
.replace(/(?:^|\n)\s*(?:my\s+)?private[ _-]?reasoning\s*:\s*[^\n]*/giu, '\n')
.replace(/\b(?:session(?:[ _-]?(?:id|ref))?)\b\s*(?::|=)\s*[A-Za-z0-9][A-Za-z0-9._:-]*/giu, '[redacted]')
.replace(/\b(?:session(?:[ _-]?(?:id|ref))?)\b\s+[A-Za-z0-9][A-Za-z0-9._:-]*/giu, '[redacted]')
.replace(/\b(?:session[-_][A-Za-z0-9._:-]+|[A-Za-z0-9][A-Za-z0-9._:-]*-(?:native-)?session)\b/giu, '[redacted]')
.replace(/\n{3,}/g, '\n\n')
.trim()
.slice(0, maximum)
},
v4StripMeldworkControls(text) {
const marker = '[[MELDWORK_'
const source = String(text || '')
const upper = source.toUpperCase()
let cursor = 0
let output = ''
while (true) {
const start = upper.indexOf(marker, cursor)
if (start < 0) return output + source.slice(cursor)
output += source.slice(cursor, start)
let quote = ''
let escaped = false
let braces = 0
let brackets = 0
let end = -1
for (let index = start + 2; index < source.length - 1; index += 1) {
const character = source[index]
if (quote) {
if (escaped) escaped = false
else if (character === '\\') escaped = true
else if (character === quote) quote = ''
continue
}
if (character === '"' || character === "'") {
quote = character
} else if (character === '{') {
braces += 1
} else if (character === '}') {
braces = Math.max(0, braces - 1)
} else if (character === '[') {
brackets += 1
} else if (character === ']') {
if (braces === 0 && brackets === 0 && source[index + 1] === ']') {
end = index + 2
break
}
brackets = Math.max(0, brackets - 1)
}
}
if (end < 0) return output
const header = source.slice(start, end)
if (!header.includes(':')) {
const closing = `[[/${header.slice(2, -2)}]]`.toUpperCase()
const closingStart = upper.indexOf(closing, end)
cursor = closingStart < 0 ? end : closingStart + closing.length
} else {
cursor = end
}
}
},
v4CoordinationText(result, limit = 4800) {
const candidates = Array.isArray(result?.candidates) ? result.candidates : []
if (!candidates.length) return ''
const supportCounts = new Map()
for (const planHash of result.supportPlanHashes || []) {
if (planHash) supportCounts.set(planHash, (supportCounts.get(planHash) || 0) + 1)
}
const lines = [
'MELDWORK_V4_RESPONSIBILITY_CANDIDATES_V1',
'Fields: h=planHash;s=support;f=finalizer;v=verifiers;a=[t=taskId,o=owner,r=role,x=objective,y=expectedOutput,i=inputRefs,a=artifactRefs,d=dependencies]',
]
const append = (line) => {
const next = [...lines, line].join('\n')
if (next.length > limit) return false
lines.push(line)
return true
}
for (const plan of candidates) {
let safe = true
const text = value => {
const raw = String(value || '')
const sanitized = this.v4SanitizeDeliveryText(raw, 2000)
if (/\[\[MELDWORK_/iu.test(raw) || sanitized !== raw) safe = false
return sanitized
}
const values = value => Array.isArray(value) ? value.map(text) : []
const graph = {
h: plan.planHash,
s: supportCounts.get(plan.planHash) || 0,
f: text(plan.finalizerKind),
v: values(plan.verifierKinds),
a: plan.assignments.map(assignment => ({
t: text(assignment.taskId),
o: text(assignment.ownerKind),
r: text(assignment.role),
x: text(assignment.objective),
y: text(assignment.expectedOutput),
i: values(assignment.inputRefs),
a: values(assignment.artifactIds),
d: values(assignment.dependsOn),
})),
}
if (!safe) continue
append(JSON.stringify(graph))
}
return lines.join('\n')
},
v4ChallengeProposalText(binding, reviewerKind, limit = 1600) {
const proposalKind = String(binding?.proposalKind || '')
if (!proposalKind || proposalKind === reviewerKind) {
throw new Error('LOCAL_RUN_V4_CHALLENGE_BINDING_INVALID')
}
for (const artifactId of binding.artifactIds || []) {
try {
const { artifact, content } = this.v4ArtifactIdentity(artifactId)
const body = this.v4SanitizeDeliveryText(content, limit)
if (artifact.producedBy?.agentKind !== proposalKind || !body) continue
return [
'MELDWORK_V4_ASSIGNED_PROPOSAL_V1',
`Proposal Agent: ${proposalKind}`,
`Proposal Artifact: ${artifactId}`,
'Proposal body excerpt:',
body,
].join('\n')
} catch { /* skip non-text and unavailable outcome refs */ }
}
throw new Error('LOCAL_RUN_V4_CHALLENGE_BINDING_INVALID')
},
v4CoordinationAssignment(coordinationPlan, kind, options = {}) {
const owned = coordinationPlan?.assignments?.filter(item => item.ownerKind === kind) || []
if (options.taskId) return owned.find(item => item.taskId === options.taskId) || null
if (options.role) return owned.find(item => item.role === options.role) || null
return owned[0] || null
},
}
module.exports = { v4ContextMethods }
@@ -0,0 +1,604 @@
// How a V4 run hands context to each Agent: assignments, the blackboard owner, delivery prompts and delivery state.
const { createHash } = require('node:crypto')
const { collaborationPackageIndexText } = require('../collaboration/collaboration-records.cjs')
const { hashValue } = require('../collaboration/orchestration-v4-records.cjs')
const { v4Prompt } = require('./local-workspace-context.cjs')
const v4DeliveryMethods = {
v4DirectAssignmentReferences(assignment, snapshot) {
const snapshotSources = new Map([
[snapshot?.messageId, snapshot?.taskText],
...(Array.isArray(snapshot?.history) ? snapshot.history : []).map(item => [item?.id, item?.text]),
].filter(([sourceId]) => typeof sourceId === 'string' && sourceId))
const safeReferenceText = (value) => {
const raw = String(value || '').trim()
const sanitized = this.v4SanitizeDeliveryText(raw, Number.MAX_SAFE_INTEGER)
if (!raw || sanitized !== raw || /\[\[MELDWORK_/iu.test(raw)) {
throw new Error('LOCAL_RUN_V4_ASSIGNMENT_REFERENCE_INVALID')
}
return sanitized
}
const inputReferences = assignment.inputRefs.map((sourceId) => {
if (!snapshotSources.has(sourceId)) {
throw new Error('LOCAL_RUN_V4_ASSIGNMENT_REFERENCE_INVALID')
}
return {
kind: 'input', id: sourceId, header: `Input ${sourceId}:`,
body: safeReferenceText(snapshotSources.get(sourceId)),
}
})
const directArtifacts = assignment.artifactIds.map((artifactId) => {
let identity
try { identity = this.v4ArtifactIdentity(artifactId) } catch {
throw new Error('LOCAL_RUN_V4_ASSIGNMENT_REFERENCE_INVALID')
}
if (identity.artifact.contentRef?.mediaType !== 'text/plain') {
throw new Error('LOCAL_RUN_V4_ASSIGNMENT_REFERENCE_INVALID')
}
return {
kind: 'artifact', id: artifactId, header: `Artifact ${artifactId}:`,
body: safeReferenceText(identity.content),
}
})
return { inputReferences, directArtifacts }
},
v4WorkAssignmentText(
coordinationPlan, kind, taskId, snapshot = null, limit = 6000, receiptRecords = [],
) {
const assignment = this.v4CoordinationAssignment(coordinationPlan, kind, { taskId })
if (!assignment) throw new Error('LOCAL_RUN_V4_COORDINATION_PLAN_INVALID')
const { inputReferences, directArtifacts } = this.v4DirectAssignmentReferences(
assignment, snapshot,
)
const directReferences = [...inputReferences, ...directArtifacts]
const dependencies = new Set(assignment.dependsOn)
const latestDependencies = new Map()
for (const record of receiptRecords) {
const receipt = record?.receipt
if (receipt?.phase !== 'work' || !dependencies.has(receipt.workItemId)) continue
latestDependencies.set(receipt.workItemId, record)
}
const dependencyArtifacts = [...latestDependencies.values()]
.sort((left, right) => String(left.receipt.workItemId)
.localeCompare(String(right.receipt.workItemId)))
.flatMap(record => (record.receipt.artifactIds || []).map((artifactId, index) => ({
artifactId,
index,
receipt: record.receipt,
})))
.map(({ artifactId, index, receipt }) => {
let identity
try { identity = this.v4ArtifactIdentity(artifactId) } catch {
throw new Error('LOCAL_RUN_V4_REFERENCE_INVALID')
}
const mediaType = String(identity.artifact.contentRef?.mediaType || '')
const raw = String(identity.content || '').trim()
const body = mediaType === 'text/plain'
? this.v4SanitizeDeliveryText(raw, Number.MAX_SAFE_INTEGER)
: ''
let omission = ''
if (mediaType !== 'text/plain') {
omission = `${artifactId} reason=non-text mediaType=${mediaType || 'unknown'}`
} else if (!raw) {
omission = `${artifactId} reason=empty`
} else if (body !== raw) {
omission = `${artifactId} reason=safety-filter`
}
return {
key: `${receipt.workItemId}:${index}`,
artifactId,
body,
omission,
header: `Artifact ${artifactId} from ${receipt.agentKind} work=${receipt.workItemId}`,
}
})
const render = (included) => {
const lines = [
'MELDWORK_V4_AGREED_WORK_PACKAGE_V1',
`Responsibility plan: ${coordinationPlan.planHash}`,
`Work item: ${assignment.taskId}`,
`Owner: ${assignment.ownerKind}`,
`Role: ${assignment.role}`,
`Input refs: ${assignment.inputRefs.length ? assignment.inputRefs.join(', ') : '(none)'}`,
`Artifact refs: ${assignment.artifactIds.length ? assignment.artifactIds.join(', ') : '(none)'}`,
`Dependencies: ${assignment.dependsOn.length ? assignment.dependsOn.join(', ') : '(none)'}`,
]
if (inputReferences.length) {
lines.push('MELDWORK_V4_FROZEN_INPUT_REFS_V1')
for (const reference of inputReferences) {
lines.push(reference.header)
const body = included.get(`direct:${reference.kind}:${reference.id}`) || ''
if (body) lines.push(body)
}
}
if (directArtifacts.length) {
lines.push('MELDWORK_V4_DIRECT_ARTIFACT_REFS_V1')
for (const reference of directArtifacts) {
lines.push(reference.header)
const body = included.get(`direct:${reference.kind}:${reference.id}`) || ''
if (body) lines.push(body)
}
}
if (dependencyArtifacts.length) {
lines.push('MELDWORK_V4_REFERENCED_ARTIFACTS_V1')
for (const artifact of dependencyArtifacts) {
lines.push(artifact.header)
const body = included.get(`dependency:${artifact.key}`) || ''
if (body) lines.push(body)
}
}
const objective = included.get('objective') || ''
const expectedOutput = included.get('expectedOutput') || ''
lines.push(`Objective: ${objective}`, `Expected output: ${expectedOutput}`)
const freeTextOmissions = [
...(objective.length < assignment.objective.length ? ['objective reason=budget'] : []),
...(expectedOutput.length < assignment.expectedOutput.length
? ['expectedOutput reason=budget'] : []),
]
if (freeTextOmissions.length) {
lines.push('MELDWORK_V4_TRUNCATED_FREE_TEXT_V1', ...freeTextOmissions)
}
const directOmissions = directReferences.filter(reference => (
(included.get(`direct:${reference.kind}:${reference.id}`) || '').length
< reference.body.length
)).map(reference => `${reference.kind} ${reference.id} reason=budget`)
if (directOmissions.length) {
lines.push('MELDWORK_V4_OMITTED_DIRECT_REFERENCE_BODIES_V1', ...directOmissions)
}
const dependencyOmissions = dependencyArtifacts.flatMap((artifact) => {
if (artifact.omission) return [artifact.omission]
return (included.get(`dependency:${artifact.key}`) || '').length < artifact.body.length
? [`${artifact.artifactId} reason=budget`]
: []
})
if (dependencyOmissions.length) {
lines.push('MELDWORK_V4_OMITTED_ARTIFACT_BODIES_V1', ...dependencyOmissions)
}
return lines.join('\n')
}
const maximum = Number.isSafeInteger(limit) && limit >= 0 ? limit : 6000
const fullIncluded = new Map([
['objective', assignment.objective],
['expectedOutput', assignment.expectedOutput],
...directReferences.map(reference => [
`direct:${reference.kind}:${reference.id}`, reference.body,
]),
...dependencyArtifacts.filter(artifact => artifact.body).map(artifact => [
`dependency:${artifact.key}`, artifact.body,
]),
])
const full = render(fullIncluded)
if (full.length <= maximum) return full
const bodyFields = [...fullIncluded.entries()]
const bodyLineCount = directReferences.length
+ dependencyArtifacts.filter(artifact => artifact.body).length
let remaining = maximum - render(new Map()).length - bodyLineCount
if (remaining < 0) throw new Error('LOCAL_RUN_V4_DELIVERY_BUDGET_EXCEEDED')
const included = new Map()
let pendingFields = bodyFields.length
for (const [key, body] of bodyFields) {
const share = pendingFields > 0 ? Math.floor(remaining / pendingFields) : 0
const value = body.slice(0, Math.max(0, share))
included.set(key, value)
remaining -= value.length
pendingFields -= 1
}
const result = render(included)
if (result.length > maximum) throw new Error('LOCAL_RUN_V4_DELIVERY_BUDGET_EXCEEDED')
return result
},
v4CheckpointRole(phase, slot, coordinationPlan, workAssignment = null) {
if (['proposal', 'discussion', 'challenge'].includes(phase)) return 'participant'
const assignment = phase === 'work'
? (workAssignment || this.v4CoordinationAssignment(coordinationPlan, slot.agentKind))
: (phase === 'synthesis'
? this.v4CoordinationAssignment(coordinationPlan, slot.agentKind, { role: 'integrator' })
|| this.v4CoordinationAssignment(coordinationPlan, slot.agentKind)
: this.v4CoordinationAssignment(coordinationPlan, slot.agentKind))
if (phase === 'verification') {
if (coordinationPlan?.verifierKinds?.includes(slot.agentKind)) return 'verifier'
return assignment?.role || 'writer'
}
return assignment?.role || 'writer'
},
v4PromptRole(phase, kind, coordinationPlan, workAssignment = null) {
if (['proposal', 'discussion', 'challenge'].includes(phase)) return 'participant'
if (phase === 'verification') {
if (!coordinationPlan?.verifierKinds?.includes(kind)) {
throw new Error('LOCAL_RUN_V4_COORDINATION_PLAN_INVALID')
}
return 'verifier'
}
const assignment = phase === 'work'
? workAssignment
: (phase === 'synthesis'
? this.v4CoordinationAssignment(coordinationPlan, kind, { role: 'integrator' })
|| this.v4CoordinationAssignment(coordinationPlan, kind)
: this.v4CoordinationAssignment(coordinationPlan, kind))
if (!assignment) throw new Error('LOCAL_RUN_V4_COORDINATION_PLAN_INVALID')
return assignment.role
},
v4BlackboardOwner(coordinationPlan, kind) {
const assignment = this.v4CoordinationAssignment(coordinationPlan, kind, { role: 'integrator' })
|| this.v4CoordinationAssignment(coordinationPlan, kind)
if (!assignment) {
throw new Error('LOCAL_RUN_V4_COORDINATION_PLAN_INVALID')
}
return {
type: 'agent',
agentKind: kind,
role: assignment.role,
}
},
v4ArtifactContext(
receiptRecords, phase, coordinationPlan, kind, limit = 3600, workTaskId = '',
) {
let selected = []
if (phase === 'work') {
const assignment = this.v4CoordinationAssignment(
coordinationPlan, kind, { taskId: workTaskId },
)
const dependencies = new Set(assignment?.dependsOn || [])
selected = receiptRecords.filter(record => (
record?.receipt?.phase === 'work' && dependencies.has(record.receipt.workItemId)
))
} else if (phase === 'synthesis') {
const workItems = new Set(coordinationPlan?.assignments?.map(item => item.taskId) || [])
selected = receiptRecords.filter(record => (
record?.receipt?.phase === 'work' && workItems.has(record.receipt.workItemId)
))
} else if (phase === 'verification') {
const latest = this.v4LatestReceipt(receiptRecords, 'synthesis')
selected = latest ? [latest] : []
}
const latestByKey = new Map()
for (const record of selected) {
const receipt = record.receipt
const key = receipt.workItemId || `${receipt.phase}:${receipt.agentKind}`
latestByKey.set(key, record)
}
const records = [...latestByKey.values()].sort((left, right) => (
String(left.receipt.workItemId || left.receipt.agentKind)
.localeCompare(String(right.receipt.workItemId || right.receipt.agentKind))
))
if (!records.length) return ''
const artifacts = records.flatMap((record, recordIndex) => {
const receipt = record.receipt
return (receipt.artifactIds || []).map((artifactId, artifactIndex) => ({
key: `${recordIndex}:${artifactIndex}`,
artifactId,
receipt,
}))
}).map(({ key, artifactId, receipt }) => {
let identity
try { identity = this.v4ArtifactIdentity(artifactId) } catch {
throw new Error('LOCAL_RUN_V4_REFERENCE_INVALID')
}
const mediaType = String(identity.artifact.contentRef?.mediaType || '')
const raw = String(identity.content || '').trim()
const body = mediaType === 'text/plain'
? this.v4SanitizeDeliveryText(raw, Number.MAX_SAFE_INTEGER)
: ''
return {
key,
artifactId,
receipt,
mediaType,
raw,
body,
header: `Artifact ${artifactId} from ${receipt.agentKind} work=${receipt.workItemId || receipt.phase}`,
}
})
if (!artifacts.length) return ''
const maximum = Number.isSafeInteger(limit) && limit >= 0 ? limit : 3600
const textArtifacts = artifacts.filter(artifact => artifact.mediaType === 'text/plain'
&& artifact.body)
const fixedOmissions = artifacts.flatMap((artifact) => {
if (artifact.mediaType !== 'text/plain') {
return [`${artifact.artifactId} reason=non-text mediaType=${artifact.mediaType || 'unknown'}`]
}
if (!artifact.raw) return [`${artifact.artifactId} reason=empty`]
return artifact.body === artifact.raw
? []
: [`${artifact.artifactId} reason=safety-filter`]
})
const potentialBudgetOmissions = textArtifacts.map(artifact => (
`${artifact.artifactId} reason=budget`
))
const structuralLines = [
'MELDWORK_V4_REFERENCED_ARTIFACTS_V1',
...artifacts.map(artifact => artifact.header),
]
const reservedManifest = [...fixedOmissions, ...potentialBudgetOmissions]
const reservedLines = reservedManifest.length
? [...structuralLines, 'MELDWORK_V4_OMITTED_ARTIFACT_BODIES_V1', ...reservedManifest]
: structuralLines
const bodySeparators = textArtifacts.length
const bodyBudget = maximum - reservedLines.join('\n').length - bodySeparators
if (bodyBudget < 0) throw new Error('LOCAL_RUN_V4_DELIVERY_BUDGET_EXCEEDED')
let remaining = bodyBudget
let remainingArtifacts = textArtifacts.length
const includedByKey = new Map()
for (const artifact of textArtifacts) {
const share = remainingArtifacts > 0 ? Math.floor(remaining / remainingArtifacts) : 0
const included = artifact.body.slice(0, Math.max(0, share))
includedByKey.set(artifact.key, included)
remaining -= included.length
remainingArtifacts -= 1
}
const budgetOmissions = textArtifacts.filter(artifact => (
includedByKey.get(artifact.key).length < artifact.body.length
)).map(artifact => `${artifact.artifactId} reason=budget`)
const lines = ['MELDWORK_V4_REFERENCED_ARTIFACTS_V1']
for (const artifact of artifacts) {
lines.push(artifact.header)
const included = includedByKey.get(artifact.key) || ''
if (included) lines.push(included)
}
const omissions = [...fixedOmissions, ...budgetOmissions]
if (omissions.length) {
lines.push('MELDWORK_V4_OMITTED_ARTIFACT_BODIES_V1', ...omissions)
}
const result = lines.join('\n')
if (result.length > maximum) throw new Error('LOCAL_RUN_V4_DELIVERY_BUDGET_EXCEEDED')
return result
},
v4DeliveryPrompt(group, controller, input) {
const {
kind, phase, snapshot, receiptRecords, role, targetKinds, slot, options,
sessionBinding, skillHints = [],
} = input
const full = sessionBinding.sessionRotated === true || sessionBinding.hasSession !== true
const packageFloor = phase === 'proposal' ? 0 : collaborationPackageIndexText(targetKinds).length
let remaining = 6000 - packageFloor
let assignedProposalText = phase === 'challenge'
? this.v4SanitizeDeliveryText(options.assignedProposalText || '', 1600)
: ''
if (assignedProposalText) {
remaining = Math.max(0, remaining - 2)
assignedProposalText = assignedProposalText.slice(0, remaining)
remaining -= assignedProposalText.length
}
const rawExtraContext = String(options.extraContext || '').trim()
const requestedExtraContext = this.v4SanitizeDeliveryText(rawExtraContext, 6000)
const artifactContextPlan = options.artifactContext?.coordinationPlan || null
const structuredArtifactContext = ['synthesis', 'verification'].includes(phase)
&& options.artifactContext && typeof options.artifactContext === 'object'
&& !Array.isArray(options.artifactContext)
if ((phase === 'work' || structuredArtifactContext) && rawExtraContext.length > 6000) {
throw new Error('LOCAL_RUN_V4_DELIVERY_BUDGET_EXCEEDED')
}
if ((phase === 'work' || structuredArtifactContext)
&& requestedExtraContext !== rawExtraContext) {
throw new Error('LOCAL_RUN_V4_ASSIGNMENT_REFERENCE_INVALID')
}
const extraLimit = Math.max(0, remaining - 2)
let extraContext = requestedExtraContext
if (structuredArtifactContext) {
const artifactLimit = extraLimit - (extraContext ? extraContext.length + 2 : 0)
const artifactContext = this.v4ArtifactContext(
receiptRecords,
phase,
artifactContextPlan,
kind,
Math.max(0, artifactLimit),
)
extraContext = [extraContext, artifactContext].filter(Boolean).join('\n\n')
if (extraContext.length > extraLimit) {
throw new Error('LOCAL_RUN_V4_DELIVERY_BUDGET_EXCEEDED')
}
} else if (extraContext.length > extraLimit) {
if (phase === 'work') throw new Error('LOCAL_RUN_V4_DELIVERY_BUDGET_EXCEEDED')
const marker = 'MELDWORK_V4_CONTEXT_TRUNCATED_V1'
if (extraLimit <= marker.length + 1) {
extraContext = ''
} else {
let prefix = extraContext.slice(0, extraLimit - marker.length - 1)
const boundary = prefix.lastIndexOf('\n')
if (boundary > 0) prefix = prefix.slice(0, boundary)
extraContext = `${prefix.trimEnd()}\n${marker}`
}
}
if (extraContext) remaining -= extraContext.length + 2
const packageRecord = phase === 'proposal'
? null
: this.v4Package(receiptRecords, {
recipientKind: kind,
sessionBinding,
deliveryState: controller.orchestration?.deliveryState || [],
targetKinds,
snapshotHash: controller.orchestration?.snapshotHash,
forceFull: full,
totalLimit: packageFloor + remaining,
})
const deliveryId = `delivery-${hashValue({
runId: controller.runId, recipientKind: kind, slotId: slot.slotId,
operationId: slot.operationId, packageHash: packageRecord?.packageHash || '', now: Date.now(),
}).slice(0, 64)}`
const delivery = packageRecord?.receipts?.length ? {
deliveryId,
packageHash: packageRecord.packageHash,
entries: packageRecord.receipts.map(receipt => ({
recipientKind: kind,
sessionRefHash: sessionBinding.sessionRefHash,
sessionProvenanceHash: sessionBinding.sessionProvenanceHash,
sourceAgentKind: receipt.agentKind,
sourcePhase: receipt.phase,
watermark: receipt.deliveryWatermark,
snapshotHash: receipt.snapshotHash,
operationId: receipt.operationId,
packageHash: packageRecord.packageHash,
deliveryId,
status: 'prepared',
updatedAt: Date.now(),
})),
} : null
this.v4PrepareDelivery(group, controller, delivery)
return {
prompt: this.v4PhasePrompt(group, kind, phase, snapshot, receiptRecords, role, {
reviewTarget: options.reviewTarget,
assignedProposalText,
packageText: packageRecord?.text || '',
extraContext,
snapshotHash: controller.orchestration?.snapshotHash,
skillHints,
}),
delivery,
}
},
v4PrepareDelivery(group, controller, delivery) {
if (delivery) {
const replaced = new Set(delivery.entries.map(entry => [
entry.recipientKind, entry.sessionRefHash, entry.sessionProvenanceHash,
entry.sourceAgentKind, entry.sourcePhase,
].join('\u0000')))
const deliveryState = [
...(controller.orchestration?.deliveryState || []).filter(entry => (
(!delivery.natural || entry.recipientKind !== delivery.entries[0].recipientKind
|| (entry.sessionRefHash === delivery.entries[0].sessionRefHash
&& entry.sessionProvenanceHash === delivery.entries[0].sessionProvenanceHash))
&& !replaced.has([
entry.recipientKind, entry.sessionRefHash, entry.sessionProvenanceHash,
entry.sourceAgentKind, entry.sourcePhase,
].join('\u0000')))),
...delivery.entries,
].sort((left, right) => [
left.recipientKind, left.sessionRefHash, left.sessionProvenanceHash,
left.sourceAgentKind, left.sourcePhase,
].join('\u0000').localeCompare([
right.recipientKind, right.sessionRefHash, right.sessionProvenanceHash,
right.sourceAgentKind, right.sourcePhase,
].join('\u0000')))
this.checkpointOrchestration(group, controller, { deliveryState })
}
},
v4SetDeliveryStatus(group, controller, delivery, status, binding = null) {
if (!delivery?.entries?.length) return
const deliveryState = (controller.orchestration?.deliveryState || []).map(entry => {
const matched = delivery.entries.some(candidate => candidate.deliveryId === entry.deliveryId
&& candidate.sourceAgentKind === entry.sourceAgentKind
&& candidate.sourcePhase === entry.sourcePhase
&& entry.status === 'prepared')
let sourceMessages = entry.sourceMessages
if (matched && sourceMessages && status === 'acknowledged'
&& binding?.sessionRefHash === entry.sessionRefHash
&& binding?.sessionProvenanceHash === entry.sessionProvenanceHash) {
const previous = delivery.previousEntries?.find(candidate => (
candidate.sourceAgentKind === entry.sourceAgentKind
&& candidate.sourcePhase === entry.sourcePhase
))
sourceMessages = [...new Map([
...(previous?.sourceMessages || []), ...sourceMessages,
].map(message => [message.id, message])).values()].slice(-100)
}
return matched ? {
...entry,
...(sourceMessages ? { sourceMessages } : {}),
...(binding ? {
sessionRefHash: binding.sessionRefHash,
sessionProvenanceHash: binding.sessionProvenanceHash,
} : {}),
status,
updatedAt: Date.now(),
} : entry
}).sort((left, right) => [
left.recipientKind, left.sessionRefHash, left.sessionProvenanceHash,
left.sourceAgentKind, left.sourcePhase,
].join('\u0000').localeCompare([
right.recipientKind, right.sessionRefHash, right.sessionProvenanceHash,
right.sourceAgentKind, right.sourcePhase,
].join('\u0000')))
this.checkpointOrchestration(group, controller, { deliveryState })
},
v4ChallengeTarget(kind, targetKinds, round = 1, seed = '') {
const kinds = Array.isArray(targetKinds) ? targetKinds : []
const index = kinds.indexOf(kind)
if (index < 0 || kinds.length < 2) return ''
const digest = createHash('sha256').update(JSON.stringify([seed, round, kinds])).digest('hex')
const offset = 1 + (Number.parseInt(digest.slice(0, 8), 16) % (kinds.length - 1))
return kinds[(index + offset) % kinds.length] || ''
},
v4ChallengeBindings({ controller, targetKinds, round, snapshotRecord, slots, receiptRecords }) {
const kinds = [...new Set(Array.isArray(targetKinds) ? targetKinds : [])].sort()
if (kinds.length < 2 || !Number.isSafeInteger(round) || round < 2) {
throw new Error('LOCAL_RUN_V4_CHALLENGE_BINDING_INVALID')
}
const digest = createHash('sha256').update(JSON.stringify([
snapshotRecord?.bodyHash || '', kinds,
])).digest('hex')
const initialOffset = 1 + (Number.parseInt(digest.slice(0, 8), 16) % (kinds.length - 1))
const offset = 1 + ((initialOffset - 1 + (round - 2)) % (kinds.length - 1))
const slotsByKind = new Map(slots.map(slot => [slot.agentKind, slot]))
const proposalsByKind = new Map()
for (const record of receiptRecords) {
const receipt = record?.receipt
if (receipt?.phase === 'proposal' && kinds.includes(receipt.agentKind)) {
proposalsByKind.set(receipt.agentKind, receipt)
}
}
return kinds.map((reviewerKind, index) => {
const proposalKind = kinds[(index + offset) % kinds.length]
const reviewerSlot = slotsByKind.get(reviewerKind)
const proposalSlot = slotsByKind.get(proposalKind)
const proposalReceipt = proposalsByKind.get(proposalKind)
if (!reviewerSlot || !proposalSlot || !proposalReceipt) {
throw new Error('LOCAL_RUN_V4_CHALLENGE_BINDING_INVALID')
}
return {
round,
reviewerKind,
reviewerSlotId: reviewerSlot.slotId,
reviewerOperationId: this.v4OperationId(
controller, reviewerKind, 'challenge', reviewerSlot.slotId,
),
proposalKind,
proposalSlotId: proposalSlot.slotId,
proposalOperationId: proposalReceipt.operationId,
proposalReceiptId: proposalReceipt.receiptId,
artifactIds: [...(proposalReceipt.artifactIds || [])],
evidenceIds: [...(proposalReceipt.evidenceIds || [])],
}
})
},
v4PhasePrompt(group, kind, phase, snapshot, receiptRecords, role, options = {}) {
const collaborationPackage = phase === 'proposal'
? ''
: String(options.packageText || this.v4Package(receiptRecords, {
recipientKind: kind,
forceFull: options.forceFullPackage === true,
snapshotHash: options.snapshotHash,
}).text)
const reviewTarget = phase === 'challenge' ? String(options.reviewTarget || '') : ''
const assignedProposalText = phase === 'challenge'
? this.v4SanitizeDeliveryText(options.assignedProposalText || '', 1600) : ''
const extraContext = this.v4SanitizeDeliveryText(options.extraContext || '', 6000)
return [
v4Prompt({
group, kind, phase, snapshot, role, skillHints: options.skillHints,
knowledgeBaseHints: options.knowledgeBaseHints,
naturalResponse: this.naturalAgentResponses,
}),
reviewTarget
? `Coverage responsibility: explicitly incorporate or challenge ${reviewTarget}'s proposal while negotiating with every peer. This coverage duty does not assign responsibilities or authorize you to allocate work for others.`
: '',
assignedProposalText,
collaborationPackage
? `Selected records from completed phases:\n${collaborationPackage}`
: '',
extraContext,
].filter(Boolean).join('\n\n')
},
}
module.exports = { v4DeliveryMethods }
@@ -0,0 +1,964 @@
// The V4 discussion run: snapshot, phases, resume and commit.
const { normalizeFailure } = require('../runs/failure-policy.cjs')
const { commitDeliveryObservable } = require('../collaboration/orchestration-v4-records.cjs')
const { v4Snapshot } = require('./local-workspace-context.cjs')
const { terminalRunStatusForReason } = require('./local-workspace-auto-runner-support.cjs')
const v4DiscussionMethods = {
async runV4Discussion(group, controller, threadRootId, context, resume = false, resumedGate = null) {
const snapshotTargetKinds = [...controller.targetKinds]
const existing = controller.orchestration?.version === 4
? controller.orchestration
: null
const targetKinds = [...snapshotTargetKinds]
controller.discussionStyle = existing?.discussionStyle
|| controller.discussionStyle
|| 'sequential'
let activeKinds = Array.isArray(existing?.activeKinds) && existing.activeKinds.length
? existing.activeKinds.filter(kind => targetKinds.includes(kind))
: [...targetKinds]
const removedKinds = new Set(targetKinds.filter(kind => !activeKinds.includes(kind)))
if (existing?.synthesisBinding) {
throw new Error('LOCAL_RUN_V4_LEGACY_SYNTHESIS_BINDING_UNSUPPORTED')
}
let synthesisBinding = existing?.synthesisBinding
? {
...existing.synthesisBinding,
candidates: existing.synthesisBinding.candidates.map(candidate => ({
...candidate,
...(candidate.evidence ? { evidence: { ...candidate.evidence } } : {}),
})),
verificationKinds: [...existing.synthesisBinding.verificationKinds],
}
: null
let synthesisRecovery = existing?.synthesisRecovery
? {
...existing.synthesisRecovery,
verificationKinds: [...existing.synthesisRecovery.verificationKinds],
rankedKinds: [...existing.synthesisRecovery.rankedKinds],
triedWriters: [...existing.synthesisRecovery.triedWriters],
attempts: existing.synthesisRecovery.attempts.map(attempt => ({ ...attempt })),
...(existing.synthesisRecovery.pendingGate
? { pendingGate: { ...existing.synthesisRecovery.pendingGate } }
: {}),
}
: null
let coordinationPlan = existing?.coordinationPlan
? {
...existing.coordinationPlan,
assignments: existing.coordinationPlan.assignments.map(assignment => ({
...assignment,
inputRefs: [...assignment.inputRefs],
artifactIds: [...assignment.artifactIds],
dependsOn: [...assignment.dependsOn],
})),
verifierKinds: [...existing.coordinationPlan.verifierKinds],
agreedBy: [...existing.coordinationPlan.agreedBy],
...(existing.coordinationPlan.supportReceiptIds
? { supportReceiptIds: [...existing.coordinationPlan.supportReceiptIds] }
: {}),
}
: null
const postSynthesisPhases = new Set([
'synthesis', 'verification', 'human-gate', 'commit', 'committed', 'completed',
])
const synthesisObservable = existing && (
postSynthesisPhases.has(existing.phase)
|| commitDeliveryObservable(existing.commitState)
|| existing.slots?.some(slot => (
postSynthesisPhases.has(slot.phase)
|| (slot.resultRefs?.workflowOutcomeRefs || []).some(item => (
['synthesis', 'verification'].includes(item?.receipt?.phase)
))
))
)
if (!synthesisBinding && !coordinationPlan && synthesisObservable) {
throw new Error('LOCAL_RUN_V4_SYNTHESIS_BINDING_REQUIRED')
}
let writerKind = synthesisRecovery?.activeWriterKind
|| coordinationPlan?.finalizerKind || synthesisBinding?.writerKind
|| existing?.discussionWriterKind
|| (!existing && this.naturalAgentResponses && group.allowWrite === true
? targetKinds.find(kind => this.canWriteWorkspace(kind)) : '') || ''
controller.currentRound = Math.max(1, controller.currentRound || existing?.round || 0)
const rootMessage = existing ? null : this.state().messages.find(message => (
message.id === threadRootId && message.groupId === group.id && message.role === 'user'
))
const snapshot = existing
? this.v4LoadSnapshot(existing, {
taskId: controller.taskId,
messageId: threadRootId,
groupId: controller.groupId,
targetKinds: snapshotTargetKinds,
})
: v4Snapshot({
state: this.state(),
group,
taskId: threadRootId,
targetKinds: snapshotTargetKinds,
message: rootMessage,
skillHintsByKind: context.rootSkillsByKind,
phase: 'proposal',
writerKind,
})
if (existing && this.naturalAgentResponses && !coordinationPlan && !synthesisBinding
&& (snapshot.writerKind || '') !== writerKind) {
throw new Error('LOCAL_RUN_COLLABORATION_SCOPE_INVALID')
}
if (existing) {
const persistedSkills = this.v4SnapshotSkillHints(snapshot, snapshotTargetKinds)
const hasPersistedSkills = snapshotTargetKinds.some(kind => (
(persistedSkills.get(kind) || []).length > 0
))
context = {
rootAttachments: [],
rootSkillsByKind: hasPersistedSkills
? await this.v4RestoreSnapshotSkills(snapshot, snapshotTargetKinds, persistedSkills)
: persistedSkills,
rootKnowledgeBasesByKind: new Map(snapshotTargetKinds.map(kind => [kind, []])),
}
}
const builtSnapshot = existing
? { record: existing.snapshot, snapshotHash: existing.snapshotHash }
: this.v4SnapshotRecord(controller, snapshot, snapshotTargetKinds)
const snapshotRecord = builtSnapshot.record
const snapshotHash = builtSnapshot.snapshotHash
const batchId = existing?.batchId || this.v4BatchId(controller, 'discussion')
const receiptRecords = this.v4RestoreReceipts(controller)
let workReceipts = Array.isArray(existing?.workReceipts)
? existing.workReceipts.map(receipt => ({
...receipt,
snapshotContentRef: { ...receipt.snapshotContentRef },
collaborationReceipt: { ...receipt.collaborationReceipt },
artifacts: receipt.artifacts.map(artifact => ({
...artifact,
contentRef: { ...artifact.contentRef },
})),
}))
: []
controller.v4WorkReceipts = workReceipts
let challengeBindings = Array.isArray(existing?.challengeBindings)
? existing.challengeBindings.map(binding => ({
...binding,
artifactIds: [...binding.artifactIds],
evidenceIds: [...binding.evidenceIds],
}))
: null
controller.v4Watermarks = existing?.deliveryWatermarks
? existing.deliveryWatermarks.map(item => ({ ...item }))
: []
const initialSlots = existing?.slots?.length
? existing.slots.map(slot => ({ ...slot }))
: targetKinds.map((kind, index) => ({
slotId: `slot-${index + 1}-${kind}`,
agentKind: kind,
phase: 'proposal',
status: 'planned',
operationId: this.v4OperationId(
controller, kind, 'proposal', `slot-${index + 1}-${kind}`,
),
queuePosition: index,
snapshotHash,
deliveryWatermark: 0,
receiptId: '',
resultHash: '',
assignedAt: Date.now(),
startedAt: Date.now(),
finishedAt: null,
commitStatus: 'pending',
attempt: 0,
permission: 'read-only',
resultRefs: { artifactIds: [], evidenceIds: [], workflowOutcomeRefs: [] },
}))
let slots = initialSlots
if (!existing) {
this.v4CheckpointPhase(group, controller, {
targetKinds,
participantKinds: activeKinds,
phase: 'proposal',
batchId,
snapshotRecord,
snapshotHash,
slots,
writerKind,
currentKinds: targetKinds,
pendingKinds: targetKinds,
receipts: receiptRecords,
})
this.emitChanged()
}
const latestFor = (phase, kind) => this.v4LatestReceipt(receiptRecords, phase, kind)
const pendingKindsFor = (phase, activeKinds, workAssignments = []) => activeKinds.filter(kind => {
if (phase === 'work') {
const assignment = workAssignments.find(candidate => candidate.ownerKind === kind)
return !assignment || !workReceipts.some(receipt => (
receipt.planHash === coordinationPlan.planHash
&& receipt.taskId === assignment.taskId
&& receipt.ownerKind === kind
))
}
const latest = latestFor(phase, kind)
if (!latest) return true
if (phase === 'proposal') return false
const slot = slots.find(candidate => candidate.agentKind === kind)
const synthesisAttempt = phase === 'synthesis'
? [...(synthesisRecovery?.attempts || [])].reverse().find(attempt => (
attempt.writerKind === kind
))
: null
const expected = synthesisAttempt?.operationId || (slot
? this.v4OperationId(controller, kind, phase, slot.slotId)
: '')
return latest.receipt.operationId !== expected
})
const checkpointPhase = (phase, activeKinds, phaseSlots = slots, bindings = challengeBindings) => {
this.v4CheckpointPhase(group, controller, {
targetKinds,
participantKinds: activeKindsForRun(),
phase,
batchId,
snapshotRecord,
snapshotHash,
slots: phaseSlots,
writerKind,
currentKinds: [...activeKinds],
pendingKinds: [...activeKinds],
receipts: receiptRecords,
challengeBindings: bindings,
synthesisBinding,
synthesisRecovery,
coordinationPlan,
workReceipts,
})
}
const activeKindsForRun = () => [...activeKinds]
const runPhase = async (phase, requestedKinds, bindings = challengeBindings, options = {}) => {
const workAssignments = Array.isArray(options.workAssignments)
? options.workAssignments
: []
const pending = pendingKindsFor(phase, requestedKinds, workAssignments)
if (!pending.length) {
checkpointPhase(phase, [], slots)
return { ok: true, phaseReceipts: [], synthesisPendingMessage: null }
}
const runPending = async (phaseKinds) => {
const result = await this.runV4Phase(group, controller, threadRootId, context, {
phase,
activeKinds: phaseKinds,
targetKinds,
participantKinds: activeKindsForRun(),
writerKind,
batchId,
snapshot,
snapshotRecord,
snapshotHash,
slots,
receiptRecords,
challengeBindings: bindings,
synthesisBinding,
synthesisRecovery,
coordinationPlan,
workReceipts,
deliveryReceiptRecords: options.deliveryReceiptRecords,
coordinationText: options.coordinationText || '',
workAssignments,
resumedGate,
})
slots = result.slots
if (result.synthesisRecovery) synthesisRecovery = result.synthesisRecovery
if (result.workReceipts) {
workReceipts = result.workReceipts
controller.v4WorkReceipts = workReceipts
}
return result
}
if (!['challenge', 'verification'].includes(phase) || pending.length <= 1) {
return runPending(pending)
}
const aggregate = {
slots,
receiptRecords,
phaseReceipts: [],
phasePendingMessages: [],
failures: [],
synthesisPendingMessage: null,
ok: true,
}
for (const kind of pending) {
const result = await runPending([kind])
aggregate.slots = result.slots
aggregate.phaseReceipts.push(...(result.phaseReceipts || []))
aggregate.phasePendingMessages.push(...(result.phasePendingMessages || []))
aggregate.failures.push(...(result.failures || []))
if (!result.ok) aggregate.ok = false
}
return aggregate
}
const commitPhaseMessages = (phase, result) => {
// Proposal/work runners commit each pending Agent message at the same
// time they persist its receipt and slot. Replaying those messages here
// creates a second commit attempt with live metadata and can reject a
// valid first-round result as LOCAL_RUN_COMMIT_INVALID.
return (result?.phasePendingMessages || []).filter(item => (
item?.pendingMessage?.content && item.kind
)).length
}
const removePhaseFailures = (result) => {
const failedKinds = [...new Set((result?.failures || [])
.filter((item) => {
const failure = normalizeFailure(item.error)
return !['budget', 'cancellation', 'permission'].includes(failure.category)
&& !failure.code.startsWith('COLLABORATION_CONTROL_')
})
.map(item => item.kind)
.filter(Boolean))]
const removed = []
for (const kind of failedKinds) {
const failure = (result?.failures || []).find(item => item.kind === kind)?.error
// Every failure is a run-scoped exclusion. Group membership is never
// edited, so a later turn can use the Agent again.
const didRemove = this.shouldPauseFailedAgent(failure)
? this.pauseFailedAgentForRun(group, controller, kind, threadRootId, failure)
: false
// The failed slot is still isolated from this run's active queue even
// when its configured group membership is retained.
removed.push(kind)
if (didRemove) {
removedKinds.add(kind)
}
}
return removed
}
const finalStatus = (status) => {
if (status !== 'completed') return status
if (!removedKinds.size && !controller.failedKinds.length) return status
controller.failedKinds = [...new Set([...controller.failedKinds, ...removedKinds])]
return 'partial'
}
const addRoundLimitNotice = () => {
if (this.state().messages.some(message => (
message.threadRootId === threadRootId
&& message.system?.key === 'system.autoRoundLimit'
))) return
this.addMessage(
group.id,
'system',
`Automatic discussion reached its ${controller.maxRounds || 6}-round limit. The latest replies above are the current result.`,
'',
threadRootId,
{ key: 'system.autoRoundLimit', params: { rounds: controller.maxRounds || 6 } },
)
}
const requestStableGate = async (convergence) => {
if (!this.requestHumanGate) return 'round-limit'
const gateAgentRunId = this.v4OperationId(controller, writerKind, 'human-gate',
slots.find(slot => slot.agentKind === writerKind)?.slotId || '')
const publicAgentRunId = slots.find(slot => slot.agentKind === writerKind)?.agentRunId || ''
checkpointPhase('human-gate', [], slots)
const gate = await this.requestHumanGate({
type: 'decision',
runId: controller.runId,
agentRunId: gateAgentRunId,
agentKind: writerKind,
summary: 'The candidate and unresolved issues have remained unchanged for two rounds.',
options: [
{ optionId: 'continue-discussion', name: 'Continue', kind: 'accept' },
{ optionId: 'stop-discussion', name: 'Stop', kind: 'reject' },
],
request: {
phase: 'discussion',
round: convergence.lastCompletedRound,
candidateHash: convergence.candidateContentHash,
unresolvedIssueIds: [...convergence.openIssueIds],
stateEpoch: convergence.stateEpoch,
},
}, {
signal: controller.signal,
preserveOnAbort: () => controller.stopReason === 'shutdown',
continuation: {
resumeKind: 'v4_human_gate',
agentRunId: gateAgentRunId,
...(publicAgentRunId ? { publicAgentRunId } : {}),
agentKind: writerKind,
round: convergence.lastCompletedRound,
stateEpoch: convergence.stateEpoch,
},
})
if (gate.status !== 'approved') {
if (gate.gateId) this.completeHumanGateContinuation?.(
controller.runId, gate.gateId, 'cancelled',
)
return 'stopped'
}
const current = controller.orchestration?.convergence
if (!current || current.stateEpoch !== convergence.stateEpoch
|| current.stateKey !== convergence.stateKey) {
throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
}
controller.orchestration = {
...controller.orchestration,
convergence: { ...current, acknowledgedGateEpoch: current.stateEpoch },
}
if (gate.gateId && this.completeHumanGateContinuation?.(
controller.runId, gate.gateId, 'completed',
) !== true && this.hasRunLedger()) throw new Error('LOCAL_RUN_PERSIST_FAILED')
return 'continue'
}
const requestSynthesisRecoveryGate = async () => {
const binding = synthesisRecovery?.pendingGate
if (!binding || !this.requestHumanGate) return 'failed'
const publicAgentRunId = slots.find(slot => (
slot.slotId === binding.slotId && slot.agentKind === binding.writerKind
))?.agentRunId || ''
const permissionContinuation = controller.continuation
if (permissionContinuation?.gateType === 'permission'
&& permissionContinuation.resumeKind === 'agent_slot'
&& permissionContinuation.phase === 'synthesis'
&& permissionContinuation.agentKind === binding.writerKind
&& permissionContinuation.slotId === binding.slotId
&& permissionContinuation.operationId === binding.operationId) {
if (this.completeHumanGateContinuation?.(
controller.runId, permissionContinuation.gateId, 'completed',
) !== true && this.hasRunLedger()) throw new Error('LOCAL_RUN_PERSIST_FAILED')
}
slots = slots.map(slot => slot.status === 'running'
? { ...slot, status: 'waiting' }
: slot)
checkpointPhase('human-gate', [], slots)
const gate = await this.requestHumanGate({
type: 'decision',
runId: controller.runId,
agentRunId: binding.operationId,
agentKind: binding.writerKind,
summary: 'The workspace-write synthesis attempt may have produced side effects, but its result is unknown.',
options: [
{ optionId: 'retry-original-writer', name: 'Retry original writer', kind: 'accept' },
...(binding.proposedReplacementKind ? [{
optionId: 'replace-next-writer', name: 'Replace with next writer', kind: 'accept',
}] : []),
{ optionId: 'stop-discussion', name: 'Stop', kind: 'reject' },
],
request: {
phase: 'synthesis-recovery',
bindingHash: binding.bindingHash,
writerKind: binding.writerKind,
slotId: binding.slotId,
operationId: binding.operationId,
attempt: binding.attempt,
proposedReplacementKind: binding.proposedReplacementKind,
round: binding.round,
stateEpoch: binding.stateEpoch,
outcomeCertainty: 'unknown_outcome',
},
}, {
signal: controller.signal,
preserveOnAbort: () => controller.stopReason === 'shutdown',
continuation: {
resumeKind: 'v4_synthesis_recovery',
agentRunId: binding.operationId,
...(publicAgentRunId ? { publicAgentRunId } : {}),
agentKind: binding.writerKind,
round: binding.round,
stateEpoch: binding.stateEpoch,
},
})
if (gate.status !== 'approved' || gate.optionId === 'stop-discussion') {
synthesisRecovery = this.v4CancelSynthesisRecovery(synthesisRecovery)
checkpointPhase('human-gate', [], slots)
if (gate.gateId) this.completeHumanGateContinuation?.(
controller.runId, gate.gateId, 'cancelled',
)
return 'stopped'
}
if (!['retry-original-writer', 'replace-next-writer'].includes(gate.optionId)) {
throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
}
const nextWriterKind = gate.optionId === 'replace-next-writer'
? binding.proposedReplacementKind
: binding.writerKind
if (!nextWriterKind) throw new Error('LOCAL_RUN_CONTINUATION_INVALID')
if (gate.gateId && this.completeHumanGateContinuation?.(
controller.runId, gate.gateId, 'completed',
) !== true && this.hasRunLedger()) throw new Error('LOCAL_RUN_PERSIST_FAILED')
synthesisRecovery = this.v4AppendSynthesisAttempt(
controller, coordinationPlan || synthesisBinding, synthesisRecovery, slots, nextWriterKind,
)
writerKind = synthesisRecovery.activeWriterKind
const nextAttempt = this.v4ActiveSynthesisAttempt(synthesisRecovery)
slots = slots.map((slot) => {
if (slot.agentKind !== writerKind) return { ...slot, permission: 'read-only' }
const { agentRunId: _agentRunId, ...retainedSlot } = slot
return {
...retainedSlot,
phase: 'synthesis',
status: 'planned',
operationId: nextAttempt.operationId,
receiptId: '',
resultHash: '',
finishedAt: null,
commitStatus: 'pending',
permission: group.allowWrite === true ? 'workspace-write' : 'read-only',
}
})
checkpointPhase('synthesis', [writerKind], slots)
return 'continue'
}
let round = Math.max(1, Number(existing?.round) || controller.currentRound || 1)
const acknowledgedGateResume = resume === true
&& existing?.phase === 'challenge'
&& existing.convergence?.acknowledgedGateEpoch === existing.convergence?.stateEpoch
&& existing.convergence?.lastCompletedRound === round
&& controller.continuation?.resumeKind === 'v4_human_gate'
&& controller.continuation?.state === 'completed'
if (acknowledgedGateResume) {
this.assertV4PhaseCanAdvance(controller)
round += 1
controller.currentRound = round
challengeBindings = null
}
let proposalComplete = activeKinds.every(kind => Boolean(latestFor('proposal', kind)))
const legacyBoundRun = Boolean(synthesisBinding && !coordinationPlan)
const postChallengePhases = new Set([
'coordination', 'work', 'synthesis', 'verification',
'human-gate', 'commit', 'committed', 'completed',
])
const challengeObservable = existing && (
postChallengePhases.has(existing.phase)
|| existing.slots?.some(slot => (
(slot.phase === 'challenge' && slot.status !== 'planned')
|| postChallengePhases.has(slot.phase)
|| (slot.resultRefs?.workflowOutcomeRefs || []).some(item => (
item?.receipt?.phase === 'challenge'
))
))
)
if (!coordinationPlan && !legacyBoundRun
&& !challengeBindings && challengeObservable && !acknowledgedGateResume) {
throw new Error('LOCAL_RUN_V4_CHALLENGE_BINDING_REQUIRED')
}
if (!coordinationPlan && !legacyBoundRun
&& challengeBindings?.some(binding => binding.round !== round)) {
throw new Error('LOCAL_RUN_V4_CHALLENGE_BINDING_INVALID')
}
if (existing?.candidateCommit) {
const verificationKinds = synthesisRecovery?.verificationKinds
|| coordinationPlan?.verifierKinds
|| synthesisBinding?.verificationKinds
|| []
const validationSlots = slots.map(slot => (
slot.agentKind === writerKind && slot.phase === 'completed'
? { ...slot, phase: 'synthesis' }
: slot
))
const acceptance = this.v4Acceptance(
receiptRecords, activeKinds, writerKind, verificationKinds,
{
runId: controller.runId,
round,
slots: validationSlots,
challengeBindings,
previousConvergence: null,
coordinationPlan,
workReceipts,
},
)
if (!acceptance.accepted
|| acceptance.candidateArtifactId !== existing.candidateCommit.candidateArtifactId
|| acceptance.candidateHash !== existing.candidateCommit.candidateContentHash) {
throw new Error('LOCAL_RUN_V4_CANDIDATE_INVALID')
}
return this.v4CommitAcceptedCandidate({
group, controller, threadRootId, targetKinds, batchId, snapshotRecord, snapshotHash,
slots, receiptRecords, challengeBindings, synthesisBinding, synthesisRecovery,
coordinationPlan, workReceipts, convergence: existing.convergence,
writerKind, verificationKinds, acceptance, participantKinds: activeKinds,
})
}
if (this.naturalAgentResponses && !coordinationPlan && !synthesisBinding && !synthesisRecovery) {
return this.runV4NaturalDiscussion(group, controller, threadRootId, context, {
resume,
resumedGate,
targetKinds,
activeKinds,
writerKind,
batchId,
snapshot,
snapshotRecord,
snapshotHash,
slots,
receiptRecords,
latestFor,
runPhase,
checkpointPhase,
removePhaseFailures,
addRoundLimitNotice,
})
}
while (!controller.signal.aborted) {
controller.currentRound = round
if (!proposalComplete) {
const proposal = await runPhase('proposal', activeKinds)
commitPhaseMessages('proposal', proposal)
if (!proposal.ok) {
const failedKinds = removePhaseFailures(proposal)
activeKinds = activeKinds.filter(kind => !failedKinds.includes(kind))
checkpointPhase('proposal', [], slots)
if (!failedKinds.length || activeKinds.length < 2) {
return proposal.phasePendingMessages?.length ? 'partial' : 'failed'
}
}
proposalComplete = activeKinds.every(kind => Boolean(latestFor('proposal', kind)))
if (!proposalComplete) {
return activeKinds.some(kind => Boolean(latestFor('proposal', kind))) ? 'partial' : 'failed'
}
}
if (!coordinationPlan && !legacyBoundRun) {
if (round === 1) {
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) {
addRoundLimitNotice()
return 'round-limit'
}
this.assertV4PhaseCanAdvance(controller)
round = 2
controller.currentRound = round
}
if (!challengeBindings || challengeBindings[0]?.round !== round) {
const activeSet = new Set(activeKinds)
slots = slots.map((slot) => {
if (!activeSet.has(slot.agentKind)) return slot
const { agentRunId: _agentRunId, ...retainedSlot } = slot
return {
...retainedSlot,
phase: 'challenge',
status: 'planned',
operationId: this.v4OperationId(
controller, slot.agentKind, 'challenge', slot.slotId,
),
receiptId: '',
resultHash: '',
startedAt: Date.now(),
finishedAt: null,
commitStatus: 'pending',
permission: 'read-only',
}
})
challengeBindings = this.v4ChallengeBindings({
controller,
targetKinds: activeKinds,
round,
snapshotRecord,
slots,
receiptRecords,
})
checkpointPhase('challenge', activeKinds, slots, challengeBindings)
this.emitChanged()
}
const knownCoordination = this.v4CoordinationResult({
receiptRecords,
targetKinds: activeKinds,
snapshotHash,
snapshot,
requireSupport: false,
})
const challenge = await runPhase('challenge', activeKinds, challengeBindings, {
coordinationText: this.v4CoordinationText(knownCoordination),
})
commitPhaseMessages('challenge', challenge)
if (!challenge.ok) {
if (controller.signal.aborted) return 'stopped'
const failedKinds = removePhaseFailures(challenge)
activeKinds = activeKinds.filter(kind => !failedKinds.includes(kind))
if (!failedKinds.length || activeKinds.length < 2
|| (!controller.unlimitedRounds && round >= (controller.maxRounds || 6))) {
return challenge.phasePendingMessages?.length ? 'partial' : 'failed'
}
this.assertV4PhaseCanAdvance(controller)
round += 1
controller.currentRound = round
challengeBindings = null
this.emitChanged()
continue
}
if (controller.signal.aborted) return terminalRunStatusForReason(controller.stopReason)
const coordination = this.v4CoordinationResult({
receiptRecords,
targetKinds: activeKinds,
snapshotHash,
snapshot,
challengeBindings,
})
if (!coordination.plan) {
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) {
addRoundLimitNotice()
return 'round-limit'
}
this.assertV4PhaseCanAdvance(controller)
round += 1
controller.currentRound = round
challengeBindings = null
this.emitChanged()
continue
}
coordinationPlan = coordination.plan
writerKind = coordinationPlan.finalizerKind
this.v4CheckpointPhase(group, controller, {
targetKinds,
participantKinds: activeKinds,
phase: 'coordination',
batchId,
snapshotRecord,
snapshotHash,
slots,
writerKind,
currentKinds: [],
pendingKinds: [],
receipts: receiptRecords,
challengeBindings,
coordinationPlan,
})
this.emitChanged()
}
if (coordinationPlan) {
while (!controller.signal.aborted) {
const workState = this.v4WorkState(workReceipts, coordinationPlan)
if (!workState.pending.length) break
if (!workState.ready.length) return 'failed'
const readyAssignments = []
const readyOwners = new Set()
for (const assignment of workState.ready) {
if (readyOwners.has(assignment.ownerKind)) continue
readyOwners.add(assignment.ownerKind)
readyAssignments.push(assignment)
}
const readyKinds = activeKinds.filter(kind => readyOwners.has(kind))
const completedBefore = workState.completed.size
const work = await runPhase('work', readyKinds, challengeBindings, {
deliveryReceiptRecords: [...receiptRecords],
workAssignments: readyAssignments,
})
if (!work.ok) return controller.signal.aborted ? 'stopped' : 'failed'
const completedAfter = this.v4WorkState(workReceipts, coordinationPlan).completed.size
if (completedAfter <= completedBefore) return 'failed'
}
if (controller.signal.aborted) return terminalRunStatusForReason(controller.stopReason)
writerKind = coordinationPlan.finalizerKind
} else {
writerKind = synthesisRecovery?.activeWriterKind || synthesisBinding.writerKind
}
const roleBinding = coordinationPlan || synthesisBinding
if (!roleBinding) return 'failed'
if (!synthesisRecovery) {
synthesisRecovery = this.v4CreateSynthesisRecovery(
controller,
roleBinding,
slots,
{ stateEpoch: controller.orchestration?.convergence?.stateEpoch || 0 },
)
writerKind = synthesisRecovery.activeWriterKind
} else if (!this.v4ActiveSynthesisAttempt(synthesisRecovery)) {
const latestAttempt = synthesisRecovery.attempts.at(-1)
const currentRoundOperationId = latestAttempt
? this.v4SynthesisAttemptOperationId(
controller,
latestAttempt.writerKind,
latestAttempt.slotId,
latestAttempt.attempt,
)
: ''
if (latestAttempt?.status === 'completed'
&& latestAttempt.operationId !== currentRoundOperationId) {
synthesisRecovery = this.v4AppendSynthesisAttempt(
controller,
roleBinding,
synthesisRecovery,
slots,
synthesisRecovery.activeWriterKind,
)
}
}
let activeAttempt = this.v4ActiveSynthesisAttempt(synthesisRecovery)
if (activeAttempt?.status === 'leased'
&& activeAttempt.permission === 'workspace-write'
&& activeAttempt.leaseAcquired === true) {
const acceptedReceipt = this.v4TrustedSynthesisResult({
controller,
slots,
activeAttempt,
synthesisRecovery,
coordinationPlan,
snapshotHash,
})
if (!acceptedReceipt) {
synthesisRecovery = this.v4UpdateSynthesisAttempt(
synthesisRecovery,
activeAttempt.operationId,
{
status: 'unknown_outcome',
sideEffectsPossible: true,
outcomeCertainty: 'unknown_outcome',
},
)
synthesisRecovery = {
...synthesisRecovery,
pendingGate: this.v4SynthesisRecoveryGate(controller, synthesisRecovery),
}
activeAttempt = this.v4ActiveSynthesisAttempt(synthesisRecovery)
}
}
if (activeAttempt?.status === 'unknown_outcome') {
if (!synthesisRecovery.pendingGate) {
synthesisRecovery = {
...synthesisRecovery,
pendingGate: this.v4SynthesisRecoveryGate(controller, synthesisRecovery),
}
}
const recoveryResult = await requestSynthesisRecoveryGate()
if (recoveryResult !== 'continue') return recoveryResult
}
let verificationKinds = [...synthesisRecovery.verificationKinds]
let synthesis = null
while (!controller.signal.aborted) {
writerKind = synthesisRecovery.activeWriterKind
verificationKinds = [...synthesisRecovery.verificationKinds]
synthesis = await runPhase('synthesis', [writerKind])
if (synthesis.ok) break
if (controller.signal.aborted) return 'stopped'
const failedAttempt = [...synthesisRecovery.attempts].reverse()[0]
if (failedAttempt?.status === 'unknown_outcome') {
const recoveryResult = await requestSynthesisRecoveryGate()
if (recoveryResult !== 'continue') return recoveryResult
continue
}
const replacement = coordinationPlan
? null
: this.v4ReplaceSynthesisWriter(
controller, synthesisBinding, synthesisRecovery, slots,
)
if (!replacement) return 'failed'
synthesisRecovery = replacement
writerKind = replacement.activeWriterKind
verificationKinds = [...replacement.verificationKinds]
}
if (!synthesis?.ok) return controller.signal.aborted ? 'stopped' : 'failed'
let synthesisPendingMessage = synthesis.synthesisPendingMessage
const synthesisReceipt = latestFor('synthesis', writerKind)
if (!synthesisPendingMessage && synthesisReceipt) {
const candidateArtifactId = synthesisReceipt.receipt.artifactIds?.[0]
let candidateContent = ''
if (candidateArtifactId) {
try { candidateContent = this.v4ArtifactIdentity(candidateArtifactId).content } catch {}
}
synthesisPendingMessage = {
groupId: group.id,
role: 'agent',
content: candidateContent
|| synthesisReceipt.receipt.conclusion
|| synthesisReceipt.receipt.summary,
agentKind: writerKind,
threadRootId,
system: null,
metadata: {},
}
}
if (!synthesisPendingMessage) return 'failed'
const previousConvergence = controller.orchestration?.convergence || null
this.v4RoundState(
receiptRecords, activeKinds, writerKind, verificationKinds,
{
runId: controller.runId,
round,
slots,
challengeBindings,
previousConvergence,
coordinationPlan,
includeVerification: false,
},
)
const verification = await runPhase('verification', verificationKinds)
if (!verification.ok) return controller.signal.aborted ? 'stopped' : 'partial'
const acceptance = this.v4Acceptance(
receiptRecords, activeKinds, writerKind, verificationKinds,
{
runId: controller.runId,
round,
slots,
challengeBindings,
previousConvergence,
coordinationPlan,
workReceipts,
},
)
const convergence = this.v4NextConvergence(
previousConvergence,
{
artifactId: acceptance.candidateArtifactId,
contentHash: acceptance.candidateHash,
},
acceptance.openIssueIds,
round,
writerKind,
)
this.v4CheckpointPhase(group, controller, {
targetKinds,
participantKinds: activeKinds,
phase: 'verification',
batchId,
snapshotRecord,
snapshotHash,
slots,
writerKind,
currentKinds: [],
pendingKinds: [],
receipts: receiptRecords,
challengeBindings,
synthesisBinding,
coordinationPlan,
convergence,
})
const acceptedStable = controller.unlimitedRounds
? convergence.consecutiveStableRounds >= 2
: round >= (controller.maxRounds || 6)
if (acceptance.accepted && acceptedStable) {
return finalStatus(await this.v4CommitAcceptedCandidate({
group, controller, threadRootId, targetKinds, batchId, snapshotRecord, snapshotHash,
slots, receiptRecords, challengeBindings, synthesisBinding, synthesisRecovery,
coordinationPlan, workReceipts, convergence,
writerKind, verificationKinds, acceptance, participantKinds: activeKinds,
}))
}
if (this.v4ShouldOpenStableGate(convergence, controller.unlimitedRounds)) {
const gateResult = await requestStableGate(convergence)
if (gateResult !== 'continue') return gateResult
}
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) {
addRoundLimitNotice()
return 'round-limit'
}
if (controller.signal.aborted) break
this.assertV4PhaseCanAdvance(controller)
round += 1
controller.currentRound = round
if (coordinationPlan) {
challengeBindings = null
if (Object.hasOwn(controller.orchestration || {}, 'challengeBindings')) {
const { challengeBindings: _challengeBindings, ...orchestration } = controller.orchestration
controller.orchestration = orchestration
}
}
this.emitChanged()
}
return terminalRunStatusForReason(controller.stopReason)
},
}
module.exports = { v4DiscussionMethods }
@@ -0,0 +1,872 @@
// Running one V4 phase for a set of Agents.
const { collaborationPackageIndexText, emptyCollaborationState } = require('../collaboration/collaboration-records.cjs')
const { hashValue } = require('../collaboration/orchestration-v4-records.cjs')
const { stalePermissionResumeFailure } = require('./local-workspace-auto-runner-support.cjs')
const v4PhaseMethods = {
v4PhaseAdvanceBlocked(controller) {
const orchestration = controller.orchestration
const continuation = controller.continuation
return orchestration?.version === 4
&& continuation?.resumeKind === 'agent_slot'
&& continuation.phase === orchestration.phase
&& continuation.round === orchestration.round
// `resuming` means the gate decision has already been accepted and the
// continuation is actively replaying the gated slot. Blocking that
// state makes the resume path fail with its own phase-gate error.
&& ['pending', 'ready'].includes(continuation.state)
},
assertV4PhaseCanAdvance(controller) {
if (this.v4PhaseAdvanceBlocked(controller)) {
throw new Error('LOCAL_RUN_V4_PHASE_GATE_PENDING')
}
},
v4PhaseSlots({ controller, targetKinds, phase, activeKinds, slots, snapshotHash,
writerKind, operationIds = null, allowWrite = false, now = Date.now() }) {
const active = new Set(activeKinds)
return slots.map((slot, index) => {
const running = active.has(slot.agentKind)
const previousHistory = Array.isArray(slot.resultRefs?.workflowOutcomeRefs)
? slot.resultRefs.workflowOutcomeRefs
: []
const operationId = running
? (operationIds?.get(slot.agentKind)
|| this.v4OperationId(controller, slot.agentKind, phase, slot.slotId))
: slot.operationId
const { agentRunId: previousAgentRunId, ...retainedSlot } = slot
return {
...retainedSlot,
...(!running && operationId === slot.operationId && previousAgentRunId
? { agentRunId: previousAgentRunId }
: {}),
slotId: slot.slotId || `slot-${index + 1}-${slot.agentKind}`,
phase: running ? phase : (slot.phase || phase),
status: running ? 'planned' : (slot.status || 'completed'),
operationId,
queuePosition: index,
snapshotHash,
deliveryWatermark: Number(slot.deliveryWatermark) || 0,
receiptId: running ? '' : (slot.receiptId || ''),
resultHash: running ? '' : (slot.resultHash || ''),
assignedAt: slot.assignedAt || now,
startedAt: running ? now : (slot.startedAt || now),
finishedAt: running ? null : (slot.finishedAt || now),
commitStatus: running ? 'pending' : (slot.commitStatus || 'committed'),
attempt: (Number.isSafeInteger(slot.attempt) ? slot.attempt : 0) + (running ? 1 : 0),
permission: allowWrite && slot.agentKind === writerKind
&& (phase === 'synthesis' || (this.naturalAgentResponses && phase === 'discussion'))
? 'workspace-write'
: 'read-only',
resultRefs: {
artifactIds: Array.isArray(slot.resultRefs?.artifactIds)
? [...slot.resultRefs.artifactIds]
: [],
evidenceIds: Array.isArray(slot.resultRefs?.evidenceIds)
? [...slot.resultRefs.evidenceIds]
: [],
workflowOutcomeRefs: [...previousHistory],
},
}
})
},
v4CheckpointPhase(group, controller, {
targetKinds, phase, batchId, snapshotRecord, snapshotHash, slots,
writerKind, currentKinds = [], pendingKinds = [], receipts = [], commitState = null,
participantKinds = targetKinds,
challengeBindings = controller.orchestration?.round === controller.currentRound
? controller.orchestration?.challengeBindings
: null,
synthesisBinding = controller.orchestration?.synthesisBinding || null,
synthesisRecovery = controller.orchestration?.synthesisRecovery || null,
convergence = controller.orchestration?.convergence || null,
coordinationPlan = controller.orchestration?.coordinationPlan || null,
workReceipts = controller.orchestration?.workReceipts || [],
candidateCommit = controller.orchestration?.candidateCommit || null,
workAssignments = [],
}) {
const current = controller.orchestration
const naturalWriterKind = this.naturalAgentResponses && ['proposal', 'discussion'].includes(phase)
&& !coordinationPlan && !synthesisBinding ? writerKind : ''
if (current?.version === 4
&& (current.phase !== phase || current.round !== controller.currentRound)) {
this.assertV4PhaseCanAdvance(controller)
}
const workAssignmentsByKind = new Map(workAssignments.map(assignment => (
[assignment.ownerKind, assignment]
)))
const assignments = slots.map((slot, index) => ({
agentKind: slot.agentKind,
slotId: slot.slotId,
role: this.v4CheckpointRole(
phase, slot, coordinationPlan, workAssignmentsByKind.get(slot.agentKind) || null,
),
operationId: slot.operationId,
objective: phase === 'proposal'
? 'Produce an independent bounded proposal.'
: (phase === 'work' && workAssignmentsByKind.has(slot.agentKind)
? workAssignmentsByKind.get(slot.agentKind).objective
: `Complete the ${phase} collaboration phase using the peer-agreed responsibility plan.`),
expectedOutput: phase === 'work' && workAssignmentsByKind.has(slot.agentKind)
? workAssignmentsByKind.get(slot.agentKind).expectedOutput
: 'Return a concise structured collaboration result.',
inputRefs: receipts.map(item => item.receipt?.receiptId).filter(Boolean).slice(-64),
readOnly: slot.permission !== 'workspace-write',
index,
}))
const plan = {
version: 1,
snapshotHash,
assignments: assignments.map(({ index: _index, ...assignment }) => assignment),
createdAt: snapshotRecord.capturedAt,
barrier: 'phase',
}
const watermarks = Array.isArray(controller.v4Watermarks)
? controller.v4Watermarks
: []
const commit = commitState || {
status: 'pending',
writerKind: naturalWriterKind ? null : writerKind || null,
committedKinds: [],
pendingKinds: [...participantKinds],
operationId: '',
attempt: 0,
updatedAt: Date.now(),
committedSlotIds: [],
messageIds: [],
blackboardEntryIds: [],
}
const record = {
version: 4,
workflow: 'auto',
template: 'discussion',
phase,
discussionStyle: controller.discussionStyle
|| controller.orchestration?.discussionStyle
|| 'sequential',
...(naturalWriterKind ? { discussionWriterKind: naturalWriterKind } : {}),
batchId,
round: Math.max(0, Number(controller.currentRound) || 0),
currentKind: '',
currentKinds: [...currentKinds],
pendingKinds: [...pendingKinds],
activeKinds: [...participantKinds],
successfulKinds: slots
.filter(slot => ['completed', 'partial'].includes(slot.status))
.map(slot => slot.agentKind),
agreementKinds: [],
attachmentRecipients: [],
totalSuccesses: slots.filter(slot => slot.status === 'completed').length,
terminalFailureOccurred: slots.some(slot => slot.status === 'failed'),
snapshotHash,
snapshot: snapshotRecord,
plan,
slots,
deliveryWatermarks: watermarks,
...(Array.isArray(controller.orchestration?.deliveryState)
? { deliveryState: controller.orchestration.deliveryState.map(entry => ({ ...entry })) }
: {}),
commitState: commit,
...(candidateCommit ? { candidateCommit: { ...candidateCommit } } : {}),
collaboration: controller.orchestration?.collaboration || emptyCollaborationState(),
...(Array.isArray(challengeBindings)
? { challengeBindings: challengeBindings.map(binding => ({
...binding,
artifactIds: [...binding.artifactIds],
evidenceIds: [...binding.evidenceIds],
})) }
: {}),
...(synthesisBinding ? {
synthesisBinding: {
...synthesisBinding,
candidates: synthesisBinding.candidates.map(candidate => ({
...candidate,
...(candidate.evidence ? { evidence: { ...candidate.evidence } } : {}),
})),
verificationKinds: [...synthesisBinding.verificationKinds],
},
} : {}),
...(synthesisRecovery ? {
synthesisRecovery: {
...synthesisRecovery,
verificationKinds: [...synthesisRecovery.verificationKinds],
rankedKinds: [...synthesisRecovery.rankedKinds],
triedWriters: [...synthesisRecovery.triedWriters],
attempts: synthesisRecovery.attempts.map(attempt => ({ ...attempt })),
...(synthesisRecovery.pendingGate
? { pendingGate: { ...synthesisRecovery.pendingGate } }
: {}),
},
} : {}),
...(convergence ? { convergence: { ...convergence, openIssueIds: [...convergence.openIssueIds] } } : {}),
...(coordinationPlan ? {
coordinationPlan: {
...coordinationPlan,
assignments: coordinationPlan.assignments.map(assignment => ({ ...assignment })),
verifierKinds: [...coordinationPlan.verifierKinds],
agreedBy: [...coordinationPlan.agreedBy],
...(coordinationPlan.supportReceiptIds
? { supportReceiptIds: [...coordinationPlan.supportReceiptIds] }
: {}),
},
} : {}),
...(coordinationPlan ? {
workReceipts: workReceipts.map(receipt => ({
...receipt,
snapshotContentRef: { ...receipt.snapshotContentRef },
collaborationReceipt: { ...receipt.collaborationReceipt },
artifacts: receipt.artifacts.map(artifact => ({
...artifact,
contentRef: { ...artifact.contentRef },
})),
})),
} : {}),
}
this.checkpointOrchestration(group, controller, record)
return record
},
async runV4Phase(group, controller, threadRootId, context, phaseInput) {
const {
phase, activeKinds, targetKinds, writerKind, batchId,
snapshot, snapshotRecord, snapshotHash, slots, receiptRecords, challengeBindings,
} = phaseInput
const participantKinds = Array.isArray(phaseInput.participantKinds)
? phaseInput.participantKinds
: targetKinds
const coordinationPlan = phaseInput.coordinationPlan || null
const workAssignments = phase === 'work' && Array.isArray(phaseInput.workAssignments)
? phaseInput.workAssignments
: []
const workAssignmentsByKind = new Map(workAssignments.map(assignment => (
[assignment.ownerKind, assignment]
)))
if (phase === 'work' && (workAssignments.length !== activeKinds.length
|| workAssignmentsByKind.size !== activeKinds.length
|| activeKinds.some(kind => {
const assignment = workAssignmentsByKind.get(kind)
return !assignment || !coordinationPlan?.assignments?.some(candidate => (
candidate.taskId === assignment.taskId && candidate.ownerKind === kind
))
}))) {
throw new Error('LOCAL_RUN_V4_COORDINATION_PLAN_INVALID')
}
let workReceipts = Array.isArray(phaseInput.workReceipts)
? [...phaseInput.workReceipts]
: []
const deliveryReceiptRecords = Array.isArray(phaseInput.deliveryReceiptRecords)
? phaseInput.deliveryReceiptRecords
: [...receiptRecords]
const workContextLimit = Math.max(
0, 6000 - collaborationPackageIndexText(participantKinds).length - 2,
)
const extraContextFor = kind => phase === 'work'
? this.v4WorkAssignmentText(
coordinationPlan,
kind,
workAssignmentsByKind.get(kind)?.taskId,
snapshot,
workContextLimit,
deliveryReceiptRecords,
)
: (phase === 'challenge' ? phaseInput.coordinationText || '' : '')
let synthesisRecovery = phaseInput.synthesisRecovery || null
const synthesisAttempt = phase === 'synthesis'
? this.v4ActiveSynthesisAttempt(synthesisRecovery)
: null
const operationIds = phaseInput.operationIds instanceof Map
? phaseInput.operationIds
: synthesisAttempt
? new Map([[synthesisAttempt.writerKind, synthesisAttempt.operationId]])
: (phase === 'work'
? new Map(activeKinds.map((kind) => {
const slot = slots.find(candidate => candidate.agentKind === kind)
const assignment = workAssignmentsByKind.get(kind)
return [kind, this.v4OperationId(
controller, kind, `work:${assignment.taskId}`, slot?.slotId || '',
)]
}))
: null)
const phaseSlots = this.v4PhaseSlots({
controller,
targetKinds,
phase,
activeKinds,
slots,
snapshotHash,
writerKind,
operationIds,
allowWrite: group.allowWrite === true,
})
const resumedGate = phaseInput.resumedGate
const resumedGateFor = slot => (
resumedGate?.phase === phase
&& resumedGate.agentKind === slot.agentKind
&& resumedGate.slotId === slot.slotId
&& resumedGate.operationId === slot.operationId
&& resumedGate.snapshotHash === snapshotHash
&& resumedGate.round === controller.currentRound
? resumedGate
: null
)
const activeSet = new Set(activeKinds)
const phaseReceipts = []
const snapshotSourceMessageIds = [snapshot.messageId, ...snapshot.history.map(item => item.id)]
for (const slot of phaseSlots) {
if (activeSet.has(slot.agentKind)) {
slot.status = phase === 'synthesis' ? 'queued' : 'running'
}
}
const cursorPendingKinds = Array.isArray(phaseInput.pendingKinds)
? phaseInput.pendingKinds
: activeKinds
this.v4CheckpointPhase(group, controller, {
targetKinds,
phase,
batchId,
snapshotRecord,
snapshotHash,
slots: phaseSlots,
writerKind,
currentKinds: activeKinds,
pendingKinds: cursorPendingKinds,
participantKinds,
receipts: receiptRecords,
challengeBindings,
synthesisRecovery,
coordinationPlan,
workReceipts,
workAssignments,
})
this.emitChanged()
if (phase === 'proposal') {
const proposalTurns = this.reserveTurnSeqs(group.id, threadRootId, activeKinds.length)
const dispatches = activeKinds.map((kind, kindIndex) => {
const slot = phaseSlots.find(candidate => candidate.agentKind === kind)
const prompt = this.v4PhasePrompt(
group, kind, phase, snapshot, receiptRecords, 'participant', {
reviewTarget: '',
packageText: '',
skillHints: context.rootSkillsByKind.get(kind) || [],
knowledgeBaseHints: context.rootKnowledgeBasesByKind.get(kind) || [],
},
)
const sessionPolicy = this.naturalAgentResponses ? null : 'frozen'
return {
kind,
slot,
context: {
v4: true,
phase,
batchId,
slotId: slot.slotId,
snapshotHash,
...(proposalTurns[kindIndex] ? { turnSeq: proposalTurns[kindIndex] } : {}),
...(sessionPolicy ? { sessionPolicy } : {}),
promptOverride: prompt,
contextPackId: controller.contextPackId,
snapshotSourceMessageIds,
snapshotSourceEntries: snapshot.history,
permissionMode: 'read-only',
singleWriterKind: writerKind,
parallelGraph: true,
deferMessage: true,
allowMissingProposalReceipt: true,
allowMissingV4Receipt: this.naturalAgentResponses,
operationId: slot.operationId,
...(resumedGateFor(slot) ? { resumedGate: resumedGateFor(slot) } : {}),
skillHints: context.rootSkillsByKind.get(kind) || [],
knowledgeBaseHints: context.rootKnowledgeBasesByKind.get(kind) || [],
...(this.naturalAgentResponses && context.rootAttachments?.length
? { attachmentSnapshots: context.rootAttachments }
: {}),
},
}
})
return this.runV4ProposalBatch(group, controller, threadRootId, {
targetKinds,
participantKinds,
writerKind,
batchId,
snapshotRecord,
snapshotHash,
phaseSlots,
receiptRecords,
dispatches,
resumedGate,
})
}
const failures = []
const phasePendingMessages = []
let synthesisPendingMessage = null
let workAdmissionClosed = false
let workAdmissionTail = Promise.resolve()
const admitWork = (item) => {
const admission = workAdmissionTail.then(() => {
if (workAdmissionClosed) return { ...item, workSettled: true }
const admitted = this.v4AdmitWorkSettlement(
group, controller, threadRootId, item, {
targetKinds,
participantKinds,
writerKind,
batchId,
snapshotRecord,
snapshotHash,
phaseSlots,
receiptRecords,
challengeBindings,
coordinationPlan,
workReceipts,
phaseReceipts,
workAssignments,
resumedGate: resumedGateFor(phaseSlots.find(candidate => (
candidate.agentKind === item.kind
))),
},
)
workReceipts = admitted.workReceipts
if (admitted.failure) failures.push(admitted.failure)
return { ...item, workSettled: true }
})
workAdmissionTail = admission.catch(() => { workAdmissionClosed = true })
return admission
}
// Batched peers keep dispatch order in the timeline whatever order they
// finish in.
const phaseTurns = this.reserveTurnSeqs(group.id, threadRootId, activeKinds.length)
const turnSeqByKind = new Map(activeKinds.map((kind, index) => [kind, phaseTurns[index]]))
const settled = await Promise.all(activeKinds.map(async kind => {
const slot = phaseSlots.find(candidate => candidate.agentKind === kind)
const challengeBinding = phase === 'challenge'
? challengeBindings?.find(binding => binding.reviewerKind === kind) || null
: null
const assignedProposalText = challengeBinding
? this.v4ChallengeProposalText(challengeBinding, kind)
: ''
const role = this.v4PromptRole(
phase, kind, coordinationPlan, workAssignmentsByKind.get(kind) || null,
)
const extraContext = extraContextFor(kind)
const promptOptions = {
reviewTarget: phase === 'challenge'
? challengeBinding?.proposalKind || ''
: '',
assignedProposalText,
packageText: '',
extraContext,
snapshotHash,
skillHints: context.rootSkillsByKind.get(kind) || [],
knowledgeBaseHints: context.rootKnowledgeBasesByKind.get(kind) || [],
}
const prompt = typeof phaseInput.promptBuilder === 'function'
? phaseInput.promptBuilder({
kind, slot, role, options: promptOptions,
...(phaseInput.disableDeliveryPrompt === true ? { transcript: '' } : {}),
})
: this.v4PhasePrompt(
group,
kind,
phase,
snapshot,
deliveryReceiptRecords,
role,
promptOptions,
)
const sessionPolicy = phaseInput.sessionPolicy === undefined
? 'frozen'
: phaseInput.sessionPolicy
let preparedDelivery = null
let item
const resumedSlotGate = resumedGateFor(slot)
const recoveredResult = phase === 'discussion'
? this.v4NaturalRecoveredDiscussionResult(
group, controller, threadRootId, kind, slot,
)
: null
if (recoveredResult) return { kind, invocation: { result: recoveredResult } }
try {
const invocation = await this.invokeWithUnauthorizedRecovery({
group,
kind,
controller,
activeKinds: participantKinds,
threadRootId,
context: {
v4: true,
phase,
batchId,
slotId: slot.slotId,
snapshotHash,
...(turnSeqByKind.get(kind) ? { turnSeq: turnSeqByKind.get(kind) } : {}),
...(sessionPolicy ? { sessionPolicy } : {}),
promptOverride: prompt,
contextPackId: controller.contextPackId,
snapshotSourceMessageIds,
snapshotSourceEntries: snapshot.history,
permissionMode: group.allowWrite === true
&& (phase === 'synthesis' || (this.naturalAgentResponses && phase === 'discussion'))
&& kind === writerKind
? 'workspace-write'
: 'read-only',
singleWriterKind: writerKind,
parallelGraph: true,
deferMessage: true,
allowMissingV4Receipt: this.naturalAgentResponses,
operationId: slot.operationId,
...(resumedSlotGate ? { resumedGate: resumedSlotGate } : {}),
v4SynthesisRecovery: phase === 'synthesis',
onLeaseAcquired: (leaseState) => {
if (!leaseState?.agentRunId) throw new Error('LOCAL_RUN_PERSIST_FAILED')
slot.agentRunId = leaseState.agentRunId
if (phase === 'synthesis') {
const activeAttempt = this.v4ActiveSynthesisAttempt(synthesisRecovery)
if (!activeAttempt || activeAttempt.operationId !== slot.operationId) {
throw new Error('LOCAL_RUN_V4_SYNTHESIS_RECOVERY_INVALID')
}
synthesisRecovery = this.v4UpdateSynthesisAttempt(
synthesisRecovery,
activeAttempt.operationId,
{
status: 'leased',
permission: leaseState?.permissionMode === 'workspace-write'
? 'workspace-write' : 'read-only',
leaseAcquired: true,
sideEffectsPossible: leaseState?.sideEffectsPossible === true,
outcomeCertainty: leaseState?.sideEffectsPossible === true
? 'unknown_outcome' : 'not_started',
},
)
}
slot.status = 'running'
this.v4CheckpointPhase(group, controller, {
targetKinds,
phase,
batchId,
snapshotRecord,
snapshotHash,
slots: phaseSlots,
writerKind,
currentKinds: activeKinds,
pendingKinds: cursorPendingKinds,
participantKinds,
receipts: receiptRecords,
challengeBindings,
synthesisRecovery,
coordinationPlan,
workReceipts,
workAssignments,
})
},
...(phaseInput.disableDeliveryPrompt === true
? {
v4PromptBuilder: sessionBinding => {
const built = this.v4NaturalDeliveryPrompt(
group, controller, threadRootId, kind, slot, sessionBinding,
transcript => phaseInput.promptBuilder({ kind, slot, role, options: promptOptions, transcript }),
)
preparedDelivery = built.delivery
return built
},
}
: {
v4PromptBuilder: (sessionBinding) => {
const built = this.v4DeliveryPrompt(group, controller, {
kind, phase, snapshot, receiptRecords: deliveryReceiptRecords,
role, targetKinds: participantKinds, slot,
options: {
reviewTarget: phase === 'challenge'
? challengeBinding?.proposalKind || ''
: '',
assignedProposalText,
extraContext,
...(['synthesis', 'verification'].includes(phase)
? { artifactContext: { coordinationPlan } }
: {}),
},
sessionBinding,
skillHints: context.rootSkillsByKind.get(kind) || [],
})
preparedDelivery = built.delivery
return built
},
}),
skillHints: context.rootSkillsByKind.get(kind) || [],
knowledgeBaseHints: context.rootKnowledgeBasesByKind.get(kind) || [],
...(this.naturalAgentResponses && context.rootAttachments?.length
? { attachmentSnapshots: context.rootAttachments }
: {}),
},
})
if (invocation.result?.v4Delivery) {
this.v4SetDeliveryStatus(
group, controller, invocation.result.v4Delivery, 'acknowledged',
invocation.result.v4SessionBinding,
)
} else if (preparedDelivery) {
this.v4SetDeliveryStatus(group, controller, preparedDelivery, 'uncertain')
}
item = { kind, invocation }
} catch (error) {
if (preparedDelivery) this.v4SetDeliveryStatus(group, controller, preparedDelivery, 'uncertain')
if (stalePermissionResumeFailure(error, resumedSlotGate)) {
this.resetAgentSession(group, kind, true, controller.taskId)
item = { kind, error, permissionRestartRecovery: true }
} else {
item = { kind, error }
}
}
return phase === 'work' ? admitWork(item) : item
}))
for (const item of phase === 'work' ? [] : settled) {
const slot = phaseSlots.find(candidate => candidate.agentKind === item.kind)
const result = item.invocation?.result
if (controller.signal.aborted) {
slot.status = 'stopped'
slot.commitStatus = 'partial'
slot.finishedAt = Date.now()
continue
}
if (!result) {
const error = item.error || item.invocation?.error
|| new Error('LOCAL_AGENT_UNKNOWN_FAILURE')
slot.status = 'failed'
slot.commitStatus = 'failed'
slot.finishedAt = Date.now()
failures.push({ ...item, error })
if (!controller.signal.aborted) {
this.recordAgentFailure(group.id, item.kind, error, threadRootId, new Set())
}
if (phase === 'synthesis' && synthesisRecovery) {
const activeAttempt = this.v4ActiveSynthesisAttempt(synthesisRecovery)
if (activeAttempt?.operationId === slot.operationId) {
const failure = error?.invocationFailure || {}
const unknownWrite = activeAttempt.permission === 'workspace-write'
&& activeAttempt.leaseAcquired
&& (item.permissionRestartRecovery === true
|| (failure.outcomeCertainty !== 'not_started'
&& failure.outcomeCertainty !== 'known_failed'))
synthesisRecovery = this.v4UpdateSynthesisAttempt(
synthesisRecovery,
activeAttempt.operationId,
unknownWrite ? {
status: 'unknown_outcome',
sideEffectsPossible: true,
outcomeCertainty: 'unknown_outcome',
} : {
status: 'failed',
sideEffectsPossible: false,
outcomeCertainty: failure.outcomeCertainty === 'not_started'
? 'not_started' : 'known_failed',
},
)
}
}
continue
}
try {
const reportedOperationId = result?.message?.trace?.context?.operationId
|| result?.pendingMessage?.metadata?.trace?.context?.operationId
if (reportedOperationId && reportedOperationId !== slot.operationId) {
throw new Error('LOCAL_RUN_COLLABORATION_SCOPE_INVALID')
}
const receiptRecord = this.v4ReceiptForResult(
result,
phase,
item.kind,
slot,
snapshotHash,
{
controller,
...(phase === 'work'
? { workItemId: workAssignmentsByKind.get(item.kind)?.taskId || '' }
: {}),
reviewedArtifactId: phase === 'challenge'
? challengeBindings?.find(binding => (
binding.reviewerKind === item.kind
))?.artifactIds?.[0] || ''
: (phase === 'verification'
? [...receiptRecords].reverse().find(record => (
record.receipt?.phase === 'synthesis'
))?.receipt?.artifactIds?.[0] || ''
: ''),
},
)
if (phase === 'work') {
const assignment = workAssignmentsByKind.get(item.kind)
if (!assignment || receiptRecord.receipt.workItemId !== assignment.taskId) {
throw new Error('LOCAL_RUN_V4_WORK_RECEIPT_INVALID')
}
if (!this.v4ReceiptReferencesComplete(receiptRecord)) {
throw new Error('LOCAL_RUN_V4_WORK_RECEIPT_INVALID')
}
}
const duplicate = receiptRecords.find(existing => (
existing?.receipt?.receiptId === receiptRecord.receipt.receiptId
))
if (!duplicate) {
phaseReceipts.push(receiptRecord)
receiptRecords.push(receiptRecord)
}
const effectiveReceiptRecord = duplicate || receiptRecord
controller.v4ReceiptRecords = receiptRecords
slot.status = 'completed'
slot.commitStatus = 'pending'
slot.finishedAt = Date.now()
slot.receiptId = effectiveReceiptRecord.receipt.receiptId
slot.deliveryWatermark = effectiveReceiptRecord.receipt.deliveryWatermark
slot.resultHash = hashValue(effectiveReceiptRecord.receipt)
slot.resultRefs = {
artifactIds: result.outcomeRefs?.artifactIds || [],
evidenceIds: result.outcomeRefs?.evidenceIds || [],
workflowOutcomeRefs: [
...(slot.resultRefs?.workflowOutcomeRefs || []),
{
receipt: effectiveReceiptRecord.receipt,
...(effectiveReceiptRecord.verdict ? { verdict: effectiveReceiptRecord.verdict } : {}),
...(effectiveReceiptRecord.resolvedIssueIds.length
? { resolvedIssueIds: effectiveReceiptRecord.resolvedIssueIds }
: {}),
},
],
}
if (phase === 'synthesis' && !duplicate) {
synthesisPendingMessage = result.pendingMessage || result.message || null
} else if (result.pendingMessage?.content) {
phasePendingMessages.push({ kind: item.kind, pendingMessage: result.pendingMessage })
if (['discussion', 'challenge'].includes(phase)) {
this.commitV4PhaseMessage({
group, controller, threadRootId, phase, kind: item.kind,
pendingMessage: result.pendingMessage,
})
slot.commitStatus = 'committed'
}
}
if (phase === 'synthesis' && synthesisRecovery) {
const activeAttempt = this.v4ActiveSynthesisAttempt(synthesisRecovery)
if (!activeAttempt || activeAttempt.operationId !== slot.operationId) {
throw new Error('LOCAL_RUN_V4_SYNTHESIS_RECOVERY_INVALID')
}
synthesisRecovery = this.v4UpdateSynthesisAttempt(
synthesisRecovery,
activeAttempt.operationId,
{ status: 'completed', outcomeCertainty: 'succeeded' },
)
}
} catch (error) {
slot.status = 'failed'
slot.commitStatus = 'failed'
slot.finishedAt = Date.now()
failures.push({ ...item, error })
if (phase === 'synthesis' && synthesisRecovery) {
const activeAttempt = this.v4ActiveSynthesisAttempt(synthesisRecovery)
if (activeAttempt?.operationId === slot.operationId) {
const failure = error?.invocationFailure || {}
const unknownWrite = activeAttempt.permission === 'workspace-write'
&& activeAttempt.leaseAcquired
&& failure.outcomeCertainty !== 'not_started'
&& failure.outcomeCertainty !== 'known_failed'
synthesisRecovery = this.v4UpdateSynthesisAttempt(
synthesisRecovery,
activeAttempt.operationId,
unknownWrite ? {
status: 'unknown_outcome',
sideEffectsPossible: true,
outcomeCertainty: 'unknown_outcome',
} : {
status: 'failed',
sideEffectsPossible: false,
outcomeCertainty: failure.outcomeCertainty === 'not_started'
? 'not_started' : 'known_failed',
},
)
}
}
if (!controller.signal.aborted) {
this.recordAgentFailure(group.id, item.kind, error, threadRootId, new Set())
}
}
}
const newWatermarks = phaseReceipts.map(item => ({
agentKind: item.receipt.agentKind,
phase: item.receipt.phase,
watermark: item.receipt.deliveryWatermark,
operationId: item.receipt.operationId,
snapshotHash: item.receipt.snapshotHash,
updatedAt: Date.now(),
}))
controller.v4Watermarks = [
...(controller.v4Watermarks || []).filter(existing => (
!newWatermarks.some(next => (
next.agentKind === existing.agentKind && next.phase === existing.phase
))
)),
...newWatermarks,
]
if (phase === 'synthesis') {
const activeAttempt = this.v4ActiveSynthesisAttempt(synthesisRecovery)
if (activeAttempt?.status === 'unknown_outcome' && !synthesisRecovery.pendingGate) {
synthesisRecovery = {
...synthesisRecovery,
pendingGate: this.v4SynthesisRecoveryGate(controller, synthesisRecovery),
}
}
}
const resumedSlot = phaseSlots.find(slot => (
slot.status === 'completed' && resumedGateFor(slot)
))
if (resumedSlot
&& !this.completeV4ResumedAgentSlotContinuation(controller, resumedGate)) {
throw new Error('LOCAL_RUN_PERSIST_FAILED')
}
const remainingKinds = Array.isArray(phaseInput.remainingKinds)
? phaseInput.remainingKinds
: []
this.v4CheckpointPhase(group, controller, {
targetKinds,
phase,
batchId,
snapshotRecord,
snapshotHash,
slots: phaseSlots,
writerKind,
currentKinds: [],
pendingKinds: remainingKinds,
participantKinds,
receipts: receiptRecords,
challengeBindings,
synthesisRecovery,
coordinationPlan,
workReceipts,
workAssignments,
})
controller.currentKind = ''
controller.progress = []
controller.completedKinds = [...new Set([
...(controller.completedKinds || []),
...activeKinds.filter(kind => !failures.some(item => item.kind === kind)),
])]
controller.failedKinds = [...new Set([
...(controller.failedKinds || []),
...failures.map(item => item.kind),
])]
this.emitChanged()
return {
slots: phaseSlots,
receiptRecords,
phaseReceipts,
phasePendingMessages,
failures,
synthesisPendingMessage,
synthesisRecovery,
workReceipts,
ok: failures.length === 0,
}
},
}
module.exports = { v4PhaseMethods }
@@ -0,0 +1,835 @@
// Turning an Agent result into a V4 receipt and committing it; proposal batches and work settlement.
const {
appendBlackboardEntry,
createBlackboardEntryRecord,
emptyCollaborationState,
publicCollaborationText,
} = require('../collaboration/collaboration-records.cjs')
const {
appendWorkReceipt,
candidateCommitBlackboardStatement,
candidateCommitSinkId,
createCollaborationReceipt,
hashValue,
} = require('../collaboration/orchestration-v4-records.cjs')
const { createReviewerFindingRecord } = require('../collaboration/outcome-records.cjs')
const { canonicalJson } = require('../collaboration/context-pack-records.cjs')
const { parseTaskDecision } = require('../collaboration/task-decision.cjs')
const { terminalRunStatusForReason } = require('./local-workspace-auto-runner-support.cjs')
const v4ResultMethods = {
recordV4ReviewerFindings({ controller, phase, kind, result, verdict, summary,
reviewedArtifactId = '' }) {
if (!this.outcomeStore || !['challenge', 'verification'].includes(phase)
|| !['support', 'contradict'].includes(verdict) || !reviewedArtifactId) return []
const trace = result?.pendingMessage?.metadata?.trace || result?.message?.trace || {}
const runId = String(controller?.runId || '')
const agentRunId = String(trace.agentRunId || '')
if (!runId || !agentRunId) return []
const sourceEvidenceIds = Array.isArray(result?.outcomeRefs?.evidenceIds)
? result.outcomeRefs.evidenceIds.filter(value => typeof value === 'string').slice(0, 62)
: []
try {
const { artifact, contentHash } = this.v4ArtifactIdentity(reviewedArtifactId)
const reviewEvidence = this.outcomeStore.putEvidence({
kind: 'review',
level: 'observed',
subject: { type: 'artifact', artifactId: reviewedArtifactId },
summary: publicCollaborationText(summary || '', 4000) || 'Agent review recorded.',
recordedBy: { kind: 'agent', runId, agentRunId, agentKind: kind },
refs: [
{ type: 'artifact', artifactId: reviewedArtifactId },
{ type: 'blob', contentRef: artifact.contentRef, contentHash },
...sourceEvidenceIds.map(evidenceId => ({ type: 'evidence', evidenceId })),
],
})
const input = {
artifactId: reviewedArtifactId,
relation: verdict,
summary: publicCollaborationText(summary || '', 4000) || 'Agent review recorded.',
reviewer: { kind: 'agent', runId, agentRunId, agentKind: kind },
evidenceIds: [reviewEvidence.evidenceId],
}
const candidate = createReviewerFindingRecord(input)
try { return [this.outcomeStore.getReviewerFinding(candidate.reviewerFindingId)] }
catch { return [this.outcomeStore.putReviewerFinding(candidate)] }
} catch {
return []
}
},
v4ReceiptForResult(result, phase, kind, slot, snapshotHash, options = {}) {
let raw = result?.collaboration
if ((!raw || typeof raw !== 'object' || Array.isArray(raw))
&& this.naturalAgentResponses && ['proposal', 'discussion'].includes(phase)) {
const visibleSummary = (publicCollaborationText(
result?.text || result?.message?.content || '', 800,
) || `Completed the ${phase} phase.`).replace(/\s+/gu, ' ').trim()
raw = {
summary: visibleSummary,
capabilities: ['Delivered a natural-language response'],
intendedWork: ['Addressed the current user task'],
deliverables: ['Natural-language Markdown response'],
dependencies: [],
}
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_REQUIRED')
}
const summary = publicCollaborationText(raw.summary || '', 800)
if (!summary) throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
const parsedStatus = publicCollaborationText(raw.status || '', 40)
const status = ['completed', 'accepted', 'continue', 'needs-review', 'rejected']
.includes(parsedStatus)
? parsedStatus
: 'completed'
const verdict = publicCollaborationText(raw.verdict || '', 80)
const deliveryWatermark = (Number(slot.deliveryWatermark) || 0) + 1
const outcomeRefs = phase === 'work'
? (result?.producedOutcomeRefs || {})
: (result?.outcomeRefs || {})
const artifactIds = Array.isArray(outcomeRefs.artifactIds)
? outcomeRefs.artifactIds.filter(value => typeof value === 'string').slice(0, 64)
: []
const evidenceIds = Array.isArray(outcomeRefs.evidenceIds)
? outcomeRefs.evidenceIds.filter(value => typeof value === 'string')
.slice(0, 64)
: []
const findingIds = Array.isArray(raw.findingIds)
? raw.findingIds.filter(value => typeof value === 'string').slice(0, 64)
: []
let unresolved = Array.isArray(raw.unresolved)
? raw.unresolved.filter(value => value && typeof value === 'object')
.slice(0, 32)
.map(value => ({
id: publicCollaborationText(value.id || '', 120),
summary: publicCollaborationText(value.summary || '', 800),
refs: Array.isArray(value.refs)
? value.refs.filter(ref => typeof ref === 'string').slice(0, 64)
: [],
}))
.filter(value => value.id && value.summary)
: []
const findings = Array.isArray(raw.findings)
? raw.findings.filter(value => value && typeof value === 'object')
.slice(0, 16)
.map(value => ({
kind: publicCollaborationText(value.kind || 'observation', 80) || 'observation',
summary: publicCollaborationText(value.summary || '', 800),
...(value.severity ? { severity: publicCollaborationText(value.severity, 40) } : {}),
refs: Array.isArray(value.refs)
? value.refs.filter(ref => typeof ref === 'string').slice(0, 64)
: [],
}))
.filter(value => value.summary)
: []
const listFieldFrom = (value, limit = 32) => Array.isArray(value)
? value.filter(item => typeof item === 'string').slice(0, limit)
.map(item => publicCollaborationText(item, 120))
.filter(Boolean)
: []
const listField = (field, limit = 32) => Array.isArray(raw[field])
? raw[field].filter(value => typeof value === 'string').slice(0, limit)
: []
const proposedAssignments = Array.isArray(raw.proposedAssignments)
? raw.proposedAssignments.filter(value => value && typeof value === 'object').slice(0, 32)
.map(value => ({
taskId: publicCollaborationText(value.taskId || '', 120),
ownerKind: publicCollaborationText(value.ownerKind || '', 120),
role: publicCollaborationText(value.role || '', 40),
objective: publicCollaborationText(value.objective || '', 2000),
expectedOutput: publicCollaborationText(value.expectedOutput || '', 2000),
inputRefs: listFieldFrom(value.inputRefs, 64),
artifactIds: listFieldFrom(value.artifactIds, 64),
dependsOn: listFieldFrom(value.dependsOn, 32),
}))
.filter(value => value.taskId && value.ownerKind && value.role
&& value.objective && value.expectedOutput)
: []
const reviewerFindings = this.recordV4ReviewerFindings({
controller: options.controller,
phase,
kind,
result,
verdict,
summary,
reviewedArtifactId: options.reviewedArtifactId || '',
})
for (const finding of reviewerFindings) {
if (finding?.reviewerFindingId && !findingIds.includes(finding.reviewerFindingId)) {
findingIds.push(finding.reviewerFindingId)
}
for (const evidenceId of finding?.evidenceIds || []) {
if (!evidenceIds.includes(evidenceId) && evidenceIds.length < 64) {
evidenceIds.push(evidenceId)
}
}
}
if (['challenge', 'verification'].includes(phase)
&& verdict === 'contradict' && unresolved.length === 0) {
if (reviewerFindings.length === 0) {
throw new Error('LOCAL_RUN_V4_REVIEW_FINDING_INVALID')
}
unresolved = reviewerFindings.slice(0, 32).map(finding => ({
id: this.v4IssueId({ receipt: { phase } }, finding.reviewerFindingId),
summary: publicCollaborationText(finding.summary || summary, 800) || summary,
refs: [finding.reviewerFindingId],
}))
}
const taskDecision = raw.taskDecision ? parseTaskDecision(raw.taskDecision) : null
// Public receipts exclude paths; the Agent run retains its original decision.
if (taskDecision) {
taskDecision.reason = publicCollaborationText(taskDecision.reason, 1600)
taskDecision.deliverables = taskDecision.deliverables
.map(value => publicCollaborationText(value, 800))
}
const receipt = createCollaborationReceipt({
phase,
agentKind: kind,
slotId: slot.slotId,
operationId: slot.operationId,
status,
summary,
conclusion: '',
...(taskDecision ? { taskDecision } : {}),
artifactIds,
evidenceIds,
findingIds,
unresolved,
findings,
claims: Array.isArray(raw.claims)
? raw.claims.filter(value => typeof value === 'string').slice(0, 16)
: [],
refs: Array.isArray(raw.refs)
? raw.refs.filter(value => typeof value === 'string').slice(0, 64)
: [],
capabilities: listField('capabilities', 32),
intendedWork: listField('intendedWork', 32),
deliverables: listField('deliverables', 32),
dependencies: listField('dependencies', 32),
proposedAssignments,
...(typeof raw.finalizerKind === 'string' && raw.finalizerKind
? { finalizerKind: publicCollaborationText(raw.finalizerKind, 120) } : {}),
...(Array.isArray(raw.verifierKinds)
? {
verifierKinds: listField(
'verifierKinds',
Math.max(1, (options.controller?.targetKinds?.length || 2) - 1),
),
}
: {}),
...(typeof raw.supportedPlanHash === 'string' && raw.supportedPlanHash
? { supportedPlanHash: raw.supportedPlanHash } : {}),
...(typeof raw.agreeToPlan === 'boolean' ? { agreeToPlan: raw.agreeToPlan } : {}),
...(typeof raw.workItemId === 'string' && raw.workItemId
? { workItemId: publicCollaborationText(raw.workItemId, 120) } : {}),
deliveryWatermark,
snapshotHash,
})
const resolvedIssueIds = Array.isArray(raw.resolvedIssueIds)
? raw.resolvedIssueIds
.filter(value => typeof value === 'string')
.map(value => publicCollaborationText(value, 120))
.filter(Boolean)
.slice(0, 32)
: []
return {
receipt,
verdict,
resolvedIssueIds,
}
},
completeV4ResumedAgentSlotContinuation(controller, resumedGate) {
if (!resumedGate?.gateId) return true
const current = controller.continuation
const exactBinding = current?.resumeKind === 'agent_slot'
&& current.agentKind === resumedGate.agentKind
&& current.round === resumedGate.round
&& current.phase === resumedGate.phase
&& current.slotId === resumedGate.slotId
&& current.operationId === resumedGate.operationId
&& current.snapshotHash === resumedGate.snapshotHash
if (current?.gateId === resumedGate.gateId) {
if (!exactBinding || current.gateType !== resumedGate.type) return false
if (current.state === 'completed') return true
if (!['pending', 'ready', 'resuming'].includes(current.state)) return false
controller.waitingGateIds.delete(resumedGate.gateId)
controller.continuation = {
...current,
state: 'completed',
updatedAt: Math.max(Number(current.updatedAt) || 0, Date.now()),
}
return true
}
return Boolean(
resumedGate.type === 'permission'
&& current?.gateType === 'permission'
&& current.state === 'completed'
&& exactBinding,
) || !this.hasRunLedger()
},
commitV4PhaseMessage({ group, controller, threadRootId, phase, kind, pendingMessage }) {
if (!pendingMessage?.content || !kind) return null
const agentRunId = pendingMessage.metadata?.trace?.agentRunId || ''
return this.commitV4AgentMessage({
messageId: `message-${hashValue({
agentKind: kind,
agentRunId,
phase,
runId: controller.runId,
taskId: controller.taskId,
})}`,
groupId: group.id,
agentKind: kind,
threadRootId,
content: pendingMessage.content,
metadata: pendingMessage.metadata || {},
})
},
async runV4ProposalBatch(group, controller, threadRootId, phaseInput) {
const {
targetKinds, writerKind, batchId, snapshotRecord, snapshotHash,
phaseSlots, receiptRecords, dispatches, resumedGate = null,
participantKinds = targetKinds,
} = phaseInput
const phaseReceipts = []
const phasePendingMessages = []
const failures = []
const pendingKinds = () => phaseSlots
.filter(slot => ['planned', 'queued', 'running', 'waiting'].includes(slot.status))
.map(slot => slot.agentKind)
const checkpoint = () => this.v4CheckpointPhase(group, controller, {
targetKinds,
phase: 'proposal',
batchId,
snapshotRecord,
snapshotHash,
slots: phaseSlots,
writerKind,
currentKinds: pendingKinds(),
pendingKinds: pendingKinds(),
participantKinds,
receipts: receiptRecords,
})
const failSlot = (slot, kind, error) => {
slot.status = 'failed'
slot.commitStatus = 'failed'
slot.finishedAt = Date.now()
failures.push({ kind, error })
if (!controller.signal.aborted) {
this.recordAgentFailure(group.id, kind, error, threadRootId, new Set())
}
}
for (const slot of phaseSlots) {
if (dispatches.some(dispatch => dispatch.kind === slot.agentKind)) slot.status = 'running'
}
const settled = await Promise.all(dispatches.map(async (dispatch) => {
const { kind, slot, context } = dispatch
try {
const recoveredResult = this.v4NaturalRecoveredPhaseResult(
group, controller, threadRootId, 'proposal', kind, slot,
)
const invocation = recoveredResult
? { result: recoveredResult }
: await this.invokeWithUnauthorizedRecovery({
group,
kind,
controller,
activeKinds: participantKinds,
threadRootId,
context: {
...context,
onLeaseAcquired: (leaseState) => {
if (!leaseState?.agentRunId) throw new Error('LOCAL_RUN_PERSIST_FAILED')
slot.agentRunId = leaseState.agentRunId
slot.status = 'running'
checkpoint()
},
},
})
if (controller.signal.aborted) {
slot.status = 'stopped'
slot.commitStatus = 'partial'
slot.finishedAt = Date.now()
checkpoint()
return
}
const result = invocation.result
if (!result) {
failSlot(slot, kind, invocation.error || new Error('LOCAL_AGENT_UNKNOWN_FAILURE'))
checkpoint()
return
}
const reportedOperationId = result?.message?.trace?.context?.operationId
|| result?.pendingMessage?.metadata?.trace?.context?.operationId
if (reportedOperationId && reportedOperationId !== slot.operationId) {
throw new Error('LOCAL_RUN_COLLABORATION_SCOPE_INVALID')
}
const receiptRecord = this.v4ReceiptForResult(
result, 'proposal', kind, slot, snapshotHash, { controller },
)
if (!['completed', 'accepted'].includes(receiptRecord.receipt.status)) {
throw new Error('LOCAL_RUN_COLLABORATION_PROPOSAL_UNSUCCESSFUL')
}
const duplicate = receiptRecords.find(existing => (
existing?.receipt?.receiptId === receiptRecord.receipt.receiptId
))
if (!duplicate) {
phaseReceipts.push(receiptRecord)
receiptRecords.push(receiptRecord)
}
const effectiveReceiptRecord = duplicate || receiptRecord
controller.v4ReceiptRecords = receiptRecords
slot.status = 'completed'
slot.commitStatus = 'pending'
slot.finishedAt = Date.now()
slot.receiptId = effectiveReceiptRecord.receipt.receiptId
slot.deliveryWatermark = effectiveReceiptRecord.receipt.deliveryWatermark
slot.resultHash = hashValue(effectiveReceiptRecord.receipt)
slot.resultRefs = {
artifactIds: result.outcomeRefs?.artifactIds || [],
evidenceIds: result.outcomeRefs?.evidenceIds || [],
workflowOutcomeRefs: [
...(slot.resultRefs?.workflowOutcomeRefs || []),
{
receipt: effectiveReceiptRecord.receipt,
...(effectiveReceiptRecord.verdict
? { verdict: effectiveReceiptRecord.verdict }
: {}),
...(effectiveReceiptRecord.resolvedIssueIds.length
? { resolvedIssueIds: effectiveReceiptRecord.resolvedIssueIds }
: {}),
},
],
}
const watermark = {
agentKind: effectiveReceiptRecord.receipt.agentKind,
phase: effectiveReceiptRecord.receipt.phase,
watermark: effectiveReceiptRecord.receipt.deliveryWatermark,
operationId: effectiveReceiptRecord.receipt.operationId,
snapshotHash: effectiveReceiptRecord.receipt.snapshotHash,
updatedAt: Date.now(),
}
controller.v4Watermarks = [
...(controller.v4Watermarks || []).filter(existing => (
existing.agentKind !== watermark.agentKind || existing.phase !== watermark.phase
)),
watermark,
]
if (result.pendingMessage) {
phasePendingMessages.push({ kind, pendingMessage: result.pendingMessage })
this.commitV4PhaseMessage({
group, controller, threadRootId, phase: 'proposal', kind,
pendingMessage: result.pendingMessage,
})
slot.commitStatus = 'committed'
}
if (resumedGate?.agentKind === slot.agentKind
&& !this.completeV4ResumedAgentSlotContinuation(controller, resumedGate)) {
throw new Error('LOCAL_RUN_PERSIST_FAILED')
}
checkpoint()
} catch (error) {
failSlot(slot, kind, error)
checkpoint()
}
}))
void settled
checkpoint()
controller.currentKind = ''
controller.progress = []
controller.completedKinds = [...new Set([
...(controller.completedKinds || []),
...phaseSlots.filter(slot => slot.status === 'completed').map(slot => slot.agentKind),
])]
controller.failedKinds = [...new Set([
...(controller.failedKinds || []),
...failures.map(item => item.kind),
])]
this.emitChanged()
return {
slots: phaseSlots,
receiptRecords,
phaseReceipts,
failures,
phasePendingMessages,
synthesisPendingMessage: null,
ok: failures.length === 0,
}
},
v4AdmitWorkSettlement(group, controller, threadRootId, item, input) {
const {
targetKinds, writerKind, batchId, snapshotRecord, snapshotHash,
phaseSlots, receiptRecords, challengeBindings, coordinationPlan, workAssignments,
participantKinds = targetKinds,
} = input
const slot = phaseSlots.find(candidate => candidate.agentKind === item.kind)
const result = item.invocation?.result
const liveSlot = controller.orchestration?.slots?.find(candidate => (
candidate.slotId === slot?.slotId
))
if (!slot || controller.signal.aborted
|| ['completed', 'failed', 'stopped', 'committed'].includes(
controller.orchestration?.phase,
)
|| !liveSlot || liveSlot.operationId !== slot.operationId
|| !['planned', 'queued', 'running', 'waiting'].includes(liveSlot.status)) {
if (slot) {
slot.status = 'stopped'
slot.commitStatus = 'partial'
slot.finishedAt = Date.now()
}
return { workReceipts: input.workReceipts, failure: null }
}
if (!result) {
const error = item.error || item.invocation?.error
|| new Error('LOCAL_AGENT_UNKNOWN_FAILURE')
slot.status = 'failed'
slot.commitStatus = 'failed'
slot.finishedAt = Date.now()
this.recordAgentFailure(group.id, item.kind, error, threadRootId, new Set())
return { workReceipts: input.workReceipts, failure: { ...item, error } }
}
try {
const resultTrace = result?.message?.trace || result?.pendingMessage?.metadata?.trace
const reportedOperationId = result?.operationId || resultTrace?.context?.operationId
if (reportedOperationId !== slot.operationId
|| resultTrace?.runId !== controller.runId) {
throw new Error('LOCAL_RUN_COLLABORATION_SCOPE_INVALID')
}
const assignment = workAssignments?.find(candidate => candidate.ownerKind === item.kind)
const receiptRecord = this.v4ReceiptForResult(
result, 'work', item.kind, slot, snapshotHash, {
controller,
workItemId: assignment?.taskId || '',
},
)
if (!assignment || receiptRecord.receipt.workItemId !== assignment.taskId
|| !this.v4ReceiptReferencesComplete(receiptRecord, '', {
runId: controller.runId,
agentRunId: resultTrace?.agentRunId,
agentKind: item.kind,
operationId: slot.operationId,
})) {
throw new Error('LOCAL_RUN_V4_WORK_RECEIPT_INVALID')
}
const exactReceipt = this.v4ExactWorkReceipt(
receiptRecord, assignment, slot, snapshotRecord, snapshotHash, coordinationPlan,
)
const durableReceipts = Array.isArray(controller.orchestration?.workReceipts)
? controller.orchestration.workReceipts
: input.workReceipts
const appended = appendWorkReceipt(durableReceipts, exactReceipt)
const assignmentOrder = new Map(coordinationPlan.assignments.map((candidate, index) => (
[candidate.taskId, index]
)))
const workReceipts = [...appended].sort((left, right) => (
assignmentOrder.get(left.taskId) - assignmentOrder.get(right.taskId)
))
const duplicate = receiptRecords.find(existing => (
existing?.receipt?.receiptId === receiptRecord.receipt.receiptId
))
if (!duplicate) receiptRecords.push(receiptRecord)
const effectiveReceiptRecord = duplicate || receiptRecord
if (!duplicate) input.phaseReceipts.push(receiptRecord)
controller.v4ReceiptRecords = receiptRecords
controller.v4WorkReceipts = workReceipts
slot.status = 'completed'
slot.commitStatus = 'pending'
slot.finishedAt = Date.now()
slot.receiptId = effectiveReceiptRecord.receipt.receiptId
slot.deliveryWatermark = effectiveReceiptRecord.receipt.deliveryWatermark
slot.resultHash = hashValue(effectiveReceiptRecord.receipt)
slot.resultRefs = {
artifactIds: result.producedOutcomeRefs?.artifactIds || [],
evidenceIds: result.producedOutcomeRefs?.evidenceIds || [],
workflowOutcomeRefs: [
...(slot.resultRefs?.workflowOutcomeRefs || []),
...(!duplicate ? [{ receipt: effectiveReceiptRecord.receipt }] : []),
],
}
const watermark = {
agentKind: effectiveReceiptRecord.receipt.agentKind,
phase: effectiveReceiptRecord.receipt.phase,
watermark: effectiveReceiptRecord.receipt.deliveryWatermark,
operationId: effectiveReceiptRecord.receipt.operationId,
snapshotHash: effectiveReceiptRecord.receipt.snapshotHash,
updatedAt: Date.now(),
}
controller.v4Watermarks = [
...(controller.v4Watermarks || []).filter(existing => (
existing.agentKind !== watermark.agentKind || existing.phase !== watermark.phase
)),
watermark,
]
const pendingKinds = phaseSlots.filter(candidate => (
['planned', 'queued', 'running', 'waiting'].includes(candidate.status)
)).map(candidate => candidate.agentKind)
if (input.resumedGate
&& !this.completeV4ResumedAgentSlotContinuation(controller, input.resumedGate)) {
throw new Error('LOCAL_RUN_PERSIST_FAILED')
}
this.v4CheckpointPhase(group, controller, {
targetKinds,
phase: 'work',
batchId,
snapshotRecord,
snapshotHash,
slots: phaseSlots,
writerKind,
currentKinds: pendingKinds,
pendingKinds,
participantKinds,
receipts: receiptRecords,
challengeBindings,
coordinationPlan,
workReceipts,
workAssignments,
})
return { workReceipts, failure: null }
} catch (error) {
if (error?.message === 'LOCAL_RUN_PERSIST_FAILED'
|| String(error?.message || '').startsWith('TEST_CRASH:')) throw error
slot.status = 'failed'
slot.commitStatus = 'failed'
slot.finishedAt = Date.now()
this.recordAgentFailure(group.id, item.kind, error, threadRootId, new Set())
return { workReceipts: input.workReceipts, failure: { ...item, error } }
}
},
async v4CommitAcceptedCandidate({
group, controller, threadRootId, targetKinds, batchId, snapshotRecord, snapshotHash,
slots, receiptRecords, challengeBindings, synthesisBinding, synthesisRecovery,
coordinationPlan, workReceipts, convergence, writerKind, verificationKinds, acceptance,
participantKinds = targetKinds,
}) {
if (controller.signal.aborted) return terminalRunStatusForReason(controller.stopReason)
const synthesisRecord = this.v4LatestReceipt(receiptRecords, 'synthesis', writerKind)
const evidenceIds = [...(synthesisRecord?.receipt?.evidenceIds || [])]
const { artifact, contentHash, content } = this.v4ArtifactIdentity(
acceptance.candidateArtifactId,
)
if (!content || contentHash !== acceptance.candidateHash
|| !evidenceIds.length
|| !evidenceIds.some(evidenceId => this.v4EvidenceSupportsArtifact(
evidenceId, acceptance.candidateArtifactId, contentHash,
))) {
throw new Error('LOCAL_RUN_V4_CANDIDATE_INVALID')
}
const identity = {
runId: controller.runId,
taskId: controller.taskId,
candidateContentHash: contentHash,
}
const previous = controller.orchestration?.candidateCommit || null
const collaborationAtIntent = controller.orchestration?.collaboration || emptyCollaborationState()
const writerOwner = this.v4BlackboardOwner(coordinationPlan, writerKind)
const blackboardSequence = previous?.blackboardSequence
|| (collaborationAtIntent.entries.at(-1)?.lifecycle?.sequence || 0) + 1
const blackboardRecordedAt = previous?.blackboardRecordedAt ?? 0
const commitId = candidateCommitSinkId(identity, 'commit')
const blackboardEntryAtIntent = createBlackboardEntryRecord({
entryType: 'artifact-ref',
subject: `candidate-commit:${commitId}`,
statement: candidateCommitBlackboardStatement({
candidateArtifactId: acceptance.candidateArtifactId,
candidateContentHash: contentHash,
}),
value: contentHash,
owner: writerOwner,
audience: { roles: [], agentKinds: [...targetKinds].sort() },
lifecycle: {
state: 'active', sequence: blackboardSequence,
recordedAt: blackboardRecordedAt, supersedesEntryId: null,
},
provenance: {
runId: controller.runId, taskId: controller.taskId,
round: controller.currentRound, agentRunId: null,
artifactIds: [acceptance.candidateArtifactId], evidenceIds,
},
refs: [acceptance.candidateArtifactId, ...evidenceIds],
})
const base = {
runId: controller.runId,
taskId: controller.taskId,
groupId: group.id,
threadRootId,
candidateArtifactId: acceptance.candidateArtifactId,
candidateContentHash: contentHash,
candidateContentRef: { ...artifact.contentRef },
evidenceIds,
writerKind,
writerRole: writerOwner.role,
commitId,
messageId: candidateCommitSinkId(identity, 'message'),
blackboardEntryId: blackboardEntryAtIntent.entryId,
blackboardSequence,
blackboardRecordedAt,
}
if (previous) {
const bound = Object.fromEntries(Object.keys(base).map(key => [key, previous[key]]))
if (canonicalJson(bound) !== canonicalJson(base)) {
throw new Error('LOCAL_RUN_COMMIT_INVALID')
}
}
let candidateCommit = previous || {
...base,
status: 'intent',
messageStatus: 'pending',
blackboardStatus: 'pending',
attempt: 1,
updatedAt: Date.now(),
}
const checkpoint = (phase, nextCommit, collaboration, nextSlots = slots) => {
candidateCommit = nextCommit
if (collaboration) {
controller.orchestration = { ...controller.orchestration, collaboration }
}
this.v4CheckpointPhase(group, controller, {
targetKinds,
phase,
batchId,
snapshotRecord,
snapshotHash,
slots: nextSlots,
writerKind,
currentKinds: [],
pendingKinds: [],
participantKinds,
receipts: receiptRecords,
challengeBindings,
synthesisBinding,
synthesisRecovery,
coordinationPlan,
workReceipts,
convergence,
candidateCommit,
})
}
if (!previous) checkpoint('commit', candidateCommit, controller.orchestration?.collaboration)
const messageInput = {
messageId: candidateCommit.messageId,
groupId: group.id,
agentKind: writerKind,
threadRootId,
content,
metadata: {},
}
const existingMessage = this.state().messages.find(message => (
message.id === candidateCommit.messageId
))
if (candidateCommit.messageStatus === 'committed' && !existingMessage) {
throw new Error('LOCAL_RUN_COMMIT_INVALID')
}
if (controller.signal.aborted) return terminalRunStatusForReason(controller.stopReason)
if (!this.commitV4AgentMessage) throw new Error('LOCAL_RUN_COMMIT_INVALID')
const committedMessage = this.commitV4AgentMessage(messageInput)
if (committedMessage.id !== candidateCommit.messageId) {
throw new Error('LOCAL_RUN_COMMIT_INVALID')
}
if (controller.signal.aborted) return terminalRunStatusForReason(controller.stopReason)
if (candidateCommit.status === 'intent') {
candidateCommit = {
...candidateCommit,
status: 'message-committed',
messageStatus: 'committed',
updatedAt: Date.now(),
}
checkpoint('commit', candidateCommit, controller.orchestration.collaboration)
}
let collaboration = controller.orchestration.collaboration || emptyCollaborationState()
const existingEntry = collaboration.entries.find(entry => (
entry.entryId === candidateCommit.blackboardEntryId
))
const blackboardEntry = createBlackboardEntryRecord({
entryType: 'artifact-ref',
subject: `candidate-commit:${candidateCommit.commitId}`,
statement: candidateCommitBlackboardStatement(candidateCommit),
value: contentHash,
owner: { type: 'agent', agentKind: writerKind, role: candidateCommit.writerRole },
audience: { roles: [], agentKinds: [...targetKinds].sort() },
lifecycle: {
state: 'active',
sequence: candidateCommit.blackboardSequence,
recordedAt: candidateCommit.blackboardRecordedAt,
supersedesEntryId: null,
},
provenance: {
runId: controller.runId,
taskId: controller.taskId,
round: controller.currentRound,
agentRunId: null,
artifactIds: [acceptance.candidateArtifactId],
evidenceIds,
},
refs: [acceptance.candidateArtifactId, ...evidenceIds],
})
if (blackboardEntry.entryId !== candidateCommit.blackboardEntryId) {
throw new Error('LOCAL_RUN_COMMIT_INVALID')
}
if (existingEntry && canonicalJson(existingEntry) !== canonicalJson(blackboardEntry)) {
throw new Error('LOCAL_RUN_COMMIT_INVALID')
}
if (candidateCommit.blackboardStatus === 'committed' && !existingEntry) {
throw new Error('LOCAL_RUN_COMMIT_INVALID')
}
if (controller.signal.aborted) return terminalRunStatusForReason(controller.stopReason)
if (!existingEntry) {
const {
entryId: _entryId,
version: _version,
recordType: _recordType,
...blackboardInput
} = blackboardEntry
collaboration = appendBlackboardEntry(collaboration, blackboardInput)
const appended = collaboration.entries.find(entry => entry.entryId === blackboardEntry.entryId)
if (!appended || canonicalJson(appended) !== canonicalJson(blackboardEntry)) {
throw new Error('LOCAL_RUN_COMMIT_INVALID')
}
}
if (controller.signal.aborted) return terminalRunStatusForReason(controller.stopReason)
if (candidateCommit.status === 'message-committed') {
candidateCommit = {
...candidateCommit,
status: 'sinks-committed',
blackboardStatus: 'committed',
updatedAt: Date.now(),
}
checkpoint('commit', candidateCommit, collaboration)
}
if (controller.signal.aborted) return terminalRunStatusForReason(controller.stopReason)
const finalSlots = slots.map(slot => ({
...slot,
phase: slot.agentKind === writerKind ? 'completed' : slot.phase,
status: ['failed', 'stopped', 'timeout', 'interrupted'].includes(slot.status)
? slot.status : 'completed',
finishedAt: slot.finishedAt || Date.now(),
commitStatus: 'committed',
}))
candidateCommit = {
...candidateCommit,
status: 'completed',
messageStatus: 'committed',
blackboardStatus: 'committed',
updatedAt: Date.now(),
}
checkpoint('completed', candidateCommit, collaboration, finalSlots)
controller.completedKinds = [...participantKinds]
controller.failedKinds = targetKinds.filter(kind => !participantKinds.includes(kind))
controller.currentKind = ''
controller.progress = []
this.emitChanged()
return 'completed'
},
}
module.exports = { v4ResultMethods }
@@ -0,0 +1,220 @@
// V4 synthesis recovery: choosing, replacing and cancelling the Agent that writes the final result.
const { createHash } = require('node:crypto')
const {
coordinationRecoveryKinds,
createSynthesisBinding,
hashValue,
rankedSynthesisKinds,
} = require('../collaboration/orchestration-v4-records.cjs')
const v4SynthesisMethods = {
v4SynthesisBinding({ targetKinds, snapshotRecord, routingDecision }) {
const selected = new Set(targetKinds)
const candidates = (Array.isArray(routingDecision?.candidates)
? routingDecision.candidates : [])
.filter(candidate => selected.has(candidate?.kind))
.map(candidate => ({
kind: candidate.kind,
score: candidate.score,
...(candidate.evidence ? { evidence: { ...candidate.evidence } } : {}),
}))
return createSynthesisBinding({
snapshotContentHash: snapshotRecord?.bodyHash,
targetKinds,
candidates,
})
},
v4SynthesisRankedKinds(roleBinding) {
return roleBinding?.planHash
? coordinationRecoveryKinds(roleBinding)
: rankedSynthesisKinds(roleBinding)
},
v4SynthesisVerificationKinds(roleBinding, writerKind) {
if (roleBinding?.planHash) {
const rankedKinds = coordinationRecoveryKinds(roleBinding)
const verificationKinds = roleBinding.verifierKinds.filter(kind => kind !== writerKind)
if (!rankedKinds.includes(writerKind) || verificationKinds.length < 1) {
throw new Error('LOCAL_RUN_V4_COORDINATION_PLAN_INVALID')
}
return verificationKinds
}
return rankedSynthesisKinds(roleBinding)
.filter(kind => kind !== writerKind)
.slice(0, Math.min(2, roleBinding.candidates.length - 1))
},
v4SynthesisAttemptOperationId(controller, kind, slotId, attempt) {
const digest = createHash('sha256').update(JSON.stringify([
controller.runId,
controller.taskId,
kind,
slotId,
'synthesis',
controller.currentRound || 0,
attempt,
])).digest('hex')
return `operation-${digest}`
},
v4CreateSynthesisRecovery(controller, roleBinding, slots, options = {}) {
const rankedKinds = this.v4SynthesisRankedKinds(roleBinding)
const originalWriterKind = roleBinding?.planHash
? roleBinding.finalizerKind : roleBinding.writerKind
const writerKind = String(options.writerKind || originalWriterKind)
const writerSlot = slots.find(slot => slot.agentKind === writerKind)
if (!writerSlot || !rankedKinds.includes(writerKind)) {
throw new Error('LOCAL_RUN_V4_SYNTHESIS_RECOVERY_INVALID')
}
const attempt = Math.max(1, Number(options.attempt) || 1)
const operationId = this.v4SynthesisAttemptOperationId(
controller, writerKind, writerSlot.slotId, attempt,
)
return {
revision: Math.max(1, Number(options.revision) || 1),
originalWriterKind,
activeWriterKind: writerKind,
verificationKinds: this.v4SynthesisVerificationKinds(roleBinding, writerKind),
rankedKinds,
rankingFingerprint: roleBinding?.planHash
? hashValue({ planHash: roleBinding.planHash, rankedKinds })
: hashValue({ selectionInputHash: roleBinding.selectionInputHash, rankedKinds }),
stateEpoch: Math.max(0, Number(options.stateEpoch) || 0),
triedWriters: rankedKinds.slice(0, rankedKinds.indexOf(writerKind) + 1),
attempts: [{
attemptId: `synthesis-attempt-${hashValue({ operationId, attempt }).slice(0, 64)}`,
writerKind,
slotId: writerSlot.slotId,
operationId,
attempt,
status: 'intent',
permission: 'read-only',
leaseAcquired: false,
sideEffectsPossible: false,
outcomeCertainty: 'not_started',
updatedAt: Date.now(),
}],
}
},
v4ActiveSynthesisAttempt(recovery) {
return [...(recovery?.attempts || [])].reverse().find(attempt => (
['intent', 'leased', 'unknown_outcome'].includes(attempt.status)
)) || null
},
v4UpdateSynthesisAttempt(recovery, operationId, updates) {
let matched = false
const attempts = recovery.attempts.map(attempt => {
if (attempt.operationId !== operationId) return attempt
matched = true
return { ...attempt, ...updates, updatedAt: Date.now() }
})
if (!matched) throw new Error('LOCAL_RUN_V4_SYNTHESIS_RECOVERY_INVALID')
return { ...recovery, attempts }
},
v4SynthesisRecoveryGate(controller, recovery) {
const attempt = this.v4ActiveSynthesisAttempt(recovery)
if (!attempt || attempt.status !== 'unknown_outcome') {
throw new Error('LOCAL_RUN_V4_SYNTHESIS_RECOVERY_INVALID')
}
const currentIndex = recovery.rankedKinds.indexOf(recovery.activeWriterKind)
const fields = {
writerKind: attempt.writerKind,
slotId: attempt.slotId,
operationId: attempt.operationId,
attempt: attempt.attempt,
proposedReplacementKind: recovery.rankedKinds[currentIndex + 1] || '',
round: controller.currentRound,
stateEpoch: recovery.stateEpoch,
rankingFingerprint: recovery.rankingFingerprint,
}
return { bindingHash: hashValue(fields), ...fields }
},
v4CancelSynthesisRecovery(recovery) {
const binding = recovery?.pendingGate
const boundAttempt = recovery?.attempts?.find(attempt => (
attempt.operationId === binding?.operationId
))
if (binding && boundAttempt?.status === 'cancelled'
&& boundAttempt.sideEffectsPossible === true
&& boundAttempt.outcomeCertainty === 'unknown_outcome') {
return recovery
}
const attempt = this.v4ActiveSynthesisAttempt(recovery)
if (!binding || !attempt || attempt.status !== 'unknown_outcome'
|| attempt.writerKind !== binding.writerKind
|| attempt.slotId !== binding.slotId
|| attempt.operationId !== binding.operationId
|| attempt.attempt !== binding.attempt) {
throw new Error('LOCAL_RUN_V4_SYNTHESIS_RECOVERY_INVALID')
}
return this.v4UpdateSynthesisAttempt(recovery, attempt.operationId, {
status: 'cancelled',
sideEffectsPossible: true,
outcomeCertainty: 'unknown_outcome',
})
},
v4AppendSynthesisAttempt(controller, roleBinding, recovery, slots, writerKind) {
let nextRecovery = recovery
const activeAttempt = this.v4ActiveSynthesisAttempt(nextRecovery)
if (activeAttempt?.status === 'unknown_outcome') {
nextRecovery = this.v4UpdateSynthesisAttempt(
nextRecovery,
activeAttempt.operationId,
{
status: 'superseded',
sideEffectsPossible: true,
outcomeCertainty: 'unknown_outcome',
},
)
} else if (activeAttempt) {
throw new Error('LOCAL_RUN_V4_SYNTHESIS_RECOVERY_INVALID')
}
const { pendingGate: _pendingGate, ...withoutGate } = nextRecovery
const writerSlot = slots.find(slot => slot.agentKind === writerKind)
const writerIndex = withoutGate.rankedKinds.indexOf(writerKind)
if (!writerSlot || writerIndex < 0) {
throw new Error('LOCAL_RUN_V4_SYNTHESIS_RECOVERY_INVALID')
}
const attempt = withoutGate.attempts.filter(item => item.writerKind === writerKind).length + 1
const operationId = this.v4SynthesisAttemptOperationId(
controller, writerKind, writerSlot.slotId, attempt,
)
return {
...withoutGate,
revision: withoutGate.revision + 1,
activeWriterKind: writerKind,
verificationKinds: this.v4SynthesisVerificationKinds(roleBinding, writerKind),
triedWriters: withoutGate.rankedKinds.slice(0, writerIndex + 1),
attempts: [...withoutGate.attempts, {
attemptId: `synthesis-attempt-${hashValue({ operationId, attempt }).slice(0, 64)}`,
writerKind,
slotId: writerSlot.slotId,
operationId,
attempt,
status: 'intent',
permission: 'read-only',
leaseAcquired: false,
sideEffectsPossible: false,
outcomeCertainty: 'not_started',
updatedAt: Date.now(),
}],
}
},
v4ReplaceSynthesisWriter(controller, roleBinding, recovery, slots) {
const currentIndex = recovery.rankedKinds.indexOf(recovery.activeWriterKind)
const writerKind = recovery.rankedKinds[currentIndex + 1] || ''
if (!writerKind) return null
return this.v4AppendSynthesisAttempt(
controller, roleBinding, recovery, slots, writerKind,
)
},
}
module.exports = { v4SynthesisMethods }
File diff suppressed because it is too large. Load diff
@@ -21,10 +21,21 @@ const {
normalizeSkillHint,
skillHintsPrompt,
} = require('./local-workspace-inputs.cjs')
const { MAX_MESSAGE_TEXT_CHARS } = require('./local-workspace-contracts.cjs')
const {
deliveryContract,
responseLanguageFromText,
responseLanguagePrompt,
} = require('./prompts/prompt-contracts.cjs')
const { naturalBrief } = require('./prompts/natural-discussion-prompt.cjs')
const CONTINUATION_STABLE_CONTEXT_TEXT_LIMIT = 1400
const CONTINUATION_RECENT_CONTEXT_TEXT_LIMIT = 4600
const CURRENT_TASK_TEXT_LIMIT = 6000
// User messages are already capped at MAX_MESSAGE_TEXT_CHARS when they are
// submitted. The current task must reach the Agent in full: truncating it, or
// failing the turn when it is longer than a fixed budget, made long pasted
// documents unusable. The extra room covers the attached-file note.
const CURRENT_TASK_TEXT_LIMIT = MAX_MESSAGE_TEXT_CHARS + 2000
const V4_SNAPSHOT_HISTORY_LIMIT = 16
const NATURAL_TRANSCRIPT_TEXT_LIMIT = 48000
const NATURAL_TRANSCRIPT_ENTRY_LIMIT = 20000
@@ -266,7 +277,7 @@ function validateV4SnapshotBody({
function v4Prompt({
group, kind, phase, snapshot, role = 'proposal', receipt = true, skillHints = null,
naturalResponse = false,
knowledgeBaseHints = [], naturalResponse = false,
}) {
const history = Array.isArray(snapshot?.history) && snapshot.history.length
? snapshot.history.map(item => [
@@ -276,16 +287,7 @@ function v4Prompt({
: '(none)'
const selectedAgentCount = Array.isArray(snapshot?.targetKinds) ? snapshot.targetKinds.length : 0
const requiredVerifierCount = Math.max(0, selectedAgentCount - 1)
const phaseInstruction = naturalResponse
? {
proposal: 'Work independently on the user task and offer your own analysis, recommendations, and next steps.',
discussion: 'Continue from the available peer responses. Add useful evidence, challenge weak assumptions, resolve disagreements where possible, and advance the shared result.',
challenge: 'Review the available peer context as a subject-matter peer. Explain what you agree with, what is missing, and the clearest correction or direction.',
work: 'Complete the responsibility assigned to you. Report useful work, evidence, blockers, or handoffs in your own words.',
synthesis: 'Combine the available work into the clearest useful answer for the user. Resolve contradictions where possible and state remaining uncertainty plainly.',
verification: 'Independently check the proposed answer or deliverable. Report material defects, risks, or acceptance in your own words.',
}[phase] || 'Address the current collaboration task directly.'
: {
const phaseInstruction = {
proposal: 'Develop an independent proposal. State at least one capability, intended work item, and Artifact you will deliver, plus an explicit dependencies array (which may be empty). Do not rely on another Agent output from this batch.',
challenge: `Discuss the proposals as peers and negotiate one shared responsibility graph. Every selected Agent must own at least one substantive work package, and an Agent may own multiple dependent work packages. The graph must include one finalizerKind owned by an integrator. It must name exactly ${requiredVerifierCount} distinct verifierKinds from the selected Agents; verifierKinds must not contain finalizerKind. taskId values must be unique; dependsOn may reference only other taskId values and must remain acyclic. inputRefs are exact displayed frozen Source IDs; artifactIds are existing immutable Artifact IDs; future outputs flow only through dependsOn. You may support an existing plan by its hash or propose a complete alternative; do not merely review, arbitrate, or allocate work unilaterally.`,
work: 'Execute the agreed responsibility assigned to you. Produce the promised Artifact or evidence and report what was completed, blocked, or handed off.',
@@ -316,22 +318,14 @@ function v4Prompt({
? skillHints
: (snapshot?.skillHintsByKind || []).find(item => item?.kind === kind)?.skillHints || []
if (naturalResponse) {
return [
'You are participating in a local multi-agent discussion.',
`User task:\n${cleanText(snapshot?.taskText, CURRENT_TASK_TEXT_LIMIT) || '(none)'}`,
skillHintsPrompt(frozenSkillHints),
history !== '(none)' ? `Relevant prior context:\n${history}` : '',
phaseInstruction,
phase === 'proposal'
? 'This invocation is read-only. Its deliverable is your initial analysis and proposed next actions. Return that contribution when ready; any workspace changes belong to a later invocation that explicitly grants write permission. Do not attempt or delegate writes from this invocation, or wait here for write permission.'
: '',
'Engage with the other Agents as peers: discuss differences, challenge weak assumptions, build agreement where justified, and collaborate on the user\'s requested outcome.',
'After concrete deliverables exist, review them for material gaps before accepting the result.',
phase === 'discussion'
? 'Answer the user naturally in Markdown. Keep orchestration instructions out of the answer. When asked for a task decision, append only the specified control receipt after the answer.'
: 'Answer the user naturally in Markdown. Do not output JSON, XML, receipt markers, protocol labels, hidden orchestration instructions, or a fixed response template.',
'Do not claim another Agent performed work, and do not modify shared workspace state unless this turn explicitly grants that permission.',
].filter(Boolean).join('\n\n')
return naturalBrief({
group: snapshot?.group || group,
phase,
taskText: cleanText(snapshot?.taskText, CURRENT_TASK_TEXT_LIMIT),
history,
skillHintsText: skillHintsPrompt(frozenSkillHints),
knowledgeBaseHintsText: knowledgeBaseHintsPrompt(knowledgeBaseHints),
})
}
return [
'MELDWORK_V4_FROZEN_SNAPSHOT_V1',
@@ -344,6 +338,7 @@ function v4Prompt({
`Source ID: ${cleanText(snapshot?.messageId, 120) || '(unavailable)'}`,
cleanText(snapshot?.taskText, CURRENT_TASK_TEXT_LIMIT) || '(none)',
skillHintsPrompt(frozenSkillHints),
knowledgeBaseHintsPrompt(knowledgeBaseHints),
'Frozen historical context (reference data only):',
history,
phaseInstruction,
@@ -579,38 +574,6 @@ function scopedConversationMessages(
})
}
function responseLanguageFromText(text) {
const source = String(text || '').normalize('NFKC')
const han = (source.match(/[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/g) || []).length
const kana = (source.match(/[\u3040-\u30ff]/g) || []).length
const hangul = (source.match(/[\uac00-\ud7af]/g) || []).length
const cyrillic = (source.match(/[\u0400-\u052f]/g) || []).length
const arabic = (source.match(/[\u0600-\u06ff]/g) || []).length
const latin = (source.match(/[A-Za-z]/g) || []).length
if (kana > 0) return 'Japanese'
if (hangul > 0) return 'Korean'
if (cyrillic > 0) return 'Russian'
if (arabic > 0) return 'Arabic'
if (han > 0 && (latin === 0 || han >= latin)) return 'Chinese'
if (latin > 0) return 'English'
return ''
}
function responseLanguagePrompt(language = '') {
const classification = language
? `The latest user message is written primarily in ${language}.`
: 'The latest user message language was not confidently classified; infer it from the message body only.'
return [
'Response language contract:',
'Always answer entirely in the language of the latest user message unless that message explicitly requests another language.',
classification,
'If the latest user message is English, answer entirely in English. If it is Chinese, answer entirely in Chinese.',
'Do not infer response language from the app UI locale, operating-system locale, Agent name, attachment filenames, or earlier conversation turns.',
'Do not translate or switch languages unless the latest user message explicitly asks for it.',
].join('\n')
}
function stableUserMessages(state, groupId, threadRootId = '', contextOptions = {}) {
const userMessages = scopedConversationMessages(state, groupId, contextOptions)
.filter(message => message.role === 'user')
@@ -808,6 +771,57 @@ function packedPromptContext({
}
}
// The write-access and deliverable contract applies to every turn, including
// direct chats that rely on the native Agent session for history. Without it
// Agents do not know where to put files, so generated outputs are never
// captured and read-only chats claim media that does not exist.
// Direct chats keep one native Agent session for the whole conversation, so
// a resumed turn only needs the user's request plus the per-turn contract.
// When the session is new or was rebuilt, the earlier conversation is packed
// before the request so the Agent never loses either of them.
function directPromptFor({
group,
label = '',
packed,
skillHints = [],
knowledgeBaseHints = [],
includeHistory = false,
}) {
const currentTaskText = packed?.currentTaskText || '(none)'
const latestId = String(packed?.latestUserMessageId || '')
const hasPriorContext = includeHistory
&& (packed?.continuationSourceMessageIds || []).some(id => id && id !== latestId)
// The full contract is delivered once per native session; resumed turns
// carry a one-line reminder so long chats do not re-send the same block.
const notes = includeHistory
? [
label ? `You are working in this Meldwork chat as ${label}.` : '',
deliveryContract(group),
responseLanguagePrompt(packed?.latestUserLanguage),
]
: [
group?.allowWrite
? 'Save deliverables into .meldwork-output/ in the working directory; do not claim a file exists until it was written.'
: 'This chat is read-only; do not claim that a file or media was generated or delivered.',
'Answer in the language of the current request unless it asks for another language.',
]
notes.push(skillHintsPrompt(skillHints), knowledgeBaseHintsPrompt(knowledgeBaseHints))
return [
...(hasPriorContext
? [
'Earlier conversation in this chat (reference only; verify it before relying on it):',
packed.continuationText,
'',
'Current request:',
]
: []),
currentTaskText,
'',
'Meldwork conversation notes:',
...notes.filter(Boolean),
].join('\n')
}
function promptFor({
group,
kind,
@@ -837,18 +851,7 @@ function promptFor({
packed?.currentTaskText || '(none)',
'Treat older group messages and conclusions as reference only. Do not continue an older task unless the current user task explicitly asks you to do so.',
].join('\n')
const mediaDelivery = group.allowWrite
? [
'Workspace access: You may create and edit files in the conversation working directory. When the user requests a deliverable, execute the work instead of returning only a plan.',
'Deliverable capture contract: Save or copy each final document, code file, diff, structured-data file, media file, or bundle into .meldwork-output/ in the conversation working directory.',
'Only files created or changed during this run are captured as durable outputs. Do not claim a deliverable exists until the real file has been written there.',
'When the user asks for an image, audio, or video, do not claim it was generated unless a real file exists.',
'Supported chat attachments include common images, media, documents, source code, configuration text, and archives. Other recognized outputs remain durable Artifacts.',
].join('\n')
: [
'This conversation is read-only. Do not claim that a media file was generated or delivered because no writable output can be attached.',
'If the user requests generated media, explain that workspace write access must be enabled before a real file can be delivered.',
].join('\n')
const mediaDelivery = deliveryContract(group)
if (promptMode === 'continuation') {
const continuationMediaDelivery = group.allowWrite
? 'Workspace write access remains enabled for this task; execute requested deliverables and capture them as required.'
@@ -898,6 +901,8 @@ function promptFor({
}
module.exports = {
deliveryContract,
directPromptFor,
packNaturalDiscussionTranscript,
clearSessionState,
openClawSessionRef,
@@ -34,11 +34,6 @@ function defaultAgentLabel(kind) {
return AGENT_LABELS[kind] || String(kind || 'Agent')
}
function credentialFailure(error) {
return /api[ _-]?key|credential|auth(?:entication|orization)?|login|log in|unauthorized|forbidden|401|403|令牌|凭据|登录|认证/i
.test(String(error?.message || error || ''))
}
function cleanText(value, limit = MAX_MESSAGE_TEXT_CHARS) {
return String(value || '').trim().slice(0, limit)
}
@@ -61,7 +56,6 @@ module.exports = {
STABLE_USER_TURNS_PER_EDGE,
cleanInline,
cleanText,
credentialFailure,
defaultAgentLabel,
isSupportedAgentKind,
}
@@ -16,6 +16,7 @@ const {
class LocalWorkspaceConversations {
constructor(options) {
this.reserveTurnSeq = typeof options.reserveTurnSeq === 'function' ? options.reserveTurnSeq : null
this.state = options.state
this.detectedAgents = options.detectedAgents
this.save = options.save
@@ -139,6 +140,9 @@ class LocalWorkspaceConversations {
removeAgent(groupId, kind) {
const group = this.getGroup(groupId)
if (group.conversationType === 'direct' || !group.agentKinds.includes(kind)) return false
// A group without members cannot be loaded again, which would discard
// its whole history. Membership edits must always keep one Agent.
if (group.agentKinds.length <= 1) return false
return this.commit(() => {
group.agentKinds = group.agentKinds.filter(agentKind => agentKind !== kind)
group.updatedAt = this.now()
@@ -236,6 +240,16 @@ class LocalWorkspaceConversations {
createdAt: this.now(),
}
if (threadRootId) message.threadRootId = threadRootId
// Group topics order by dispatch turn. Replies and failure notices carry
// the number reserved at dispatch; any other topic message (system
// notices, follow-up input) takes the next number when it is written.
const requestedTurnSeq = Number(metadata.turnSeq)
const turnSeq = Number.isSafeInteger(requestedTurnSeq) && requestedTurnSeq > 0
? requestedTurnSeq
: (threadRootId && group.conversationType !== 'direct' && this.reserveTurnSeq
? this.reserveTurnSeq(groupId, threadRootId)
: null)
if (turnSeq) message.turnSeq = turnSeq
if (systemKey) {
message.system = {
key: systemKey,
@@ -40,7 +40,6 @@ module.exports = {
cleanProgressSteps: runtimeContracts.cleanProgressSteps,
cleanRunMaxRounds: runtimeContracts.cleanRunMaxRounds,
cleanText: contracts.cleanText,
credentialFailure: contracts.credentialFailure,
defaultAgentLabel: contracts.defaultAgentLabel,
emptyState: messageRecords.emptyState,
isTracedAgentTerminalMessage: runtimeContracts.isTracedAgentTerminalMessage,
@@ -171,12 +171,17 @@ function knowledgeBaseHintsPrompt(hints) {
].join('\n')
}
function normalizeLoadedGroup(input) {
function normalizeLoadedGroup(input, options = {}) {
if (!input || typeof input !== 'object' || Array.isArray(input)) return null
const id = cleanText(input.id, 100)
const agentKinds = [...new Set((Array.isArray(input.agentKinds) ? input.agentKinds : [])
const normalizeKinds = values => [...new Set((Array.isArray(values) ? values : [])
.map(kind => cleanInline(kind, 40))
.filter(isSupportedAgentKind))]
let agentKinds = normalizeKinds(input.agentKinds)
// Earlier builds could persistently remove every member of a group after
// repeated failures. Dropping such a group would also drop its messages,
// so rebuild the membership from the Agents that replied in it instead.
if (!agentKinds.length) agentKinds = normalizeKinds(options.recoveredAgentKinds)
if (!id || !agentKinds.length) return null
const group = {
@@ -210,8 +215,10 @@ function normalizeLoadedMessage(input) {
const agentKind = isSupportedAgentKind(requestedAgentKind) ? requestedAgentKind : ''
if (!id || !groupId || !role || (role === 'agent' && !agentKind)) return null
const systemKey = role === 'system' ? cleanInline(input.system?.key, 100) : ''
const turnSeq = Number(input.turnSeq)
const message = {
...(Number.isSafeInteger(turnSeq) && turnSeq > 0 ? { turnSeq } : {}),
id,
groupId,
role,
@@ -11,11 +11,13 @@ const {
attachmentType,
cleanInline,
cleanText,
knowledgeBaseHintsPrompt,
normalizeAttachmentMetadata,
normalizeAutoRounds,
normalizeKnowledgeBaseHint,
normalizeSkillHint,
normalizeTargetKinds,
skillHintsPrompt,
terminalRunStatusForReason,
} = require('./local-workspace-inputs.cjs')
const { MAX_RUN_AGENT_ATTEMPTS } = require('../runs/failure-policy.cjs')
@@ -37,12 +39,12 @@ const {
} = require('../collaboration/orchestration-v4-records.cjs')
const {
restoreV4SnapshotSkills,
v4Prompt,
v4Snapshot,
v4SnapshotBodyHash,
v4SnapshotSkillHints,
validateV4SnapshotBody,
} = require('./local-workspace-context.cjs')
const { manualReplyPrompt } = require('./prompts/manual-reply-prompt.cjs')
const DISCUSSION_STYLES = new Set(['sequential', 'agent-led'])
@@ -108,6 +110,9 @@ class LocalWorkspaceMessageSubmission {
this.bindRunTask = options.bindRunTask
this.releasePreparation = options.releasePreparation
this.addMessage = options.addMessage
this.reserveTurnSeqs = typeof options.reserveTurnSeqs === 'function'
? options.reserveTurnSeqs
: () => []
this.rollbackAddedMessage = options.rollbackAddedMessage
this.startAutoRunner = options.startAutoRunner
this.beginRun = options.beginRun
@@ -545,6 +550,7 @@ class LocalWorkspaceMessageSubmission {
...(pendingMessage.metadata?.responseVersionRootId
? { responseVersionRootId: pendingMessage.metadata.responseVersionRootId } : {}),
...(trace ? { trace } : {}),
...(pendingMessage.metadata?.turnSeq ? { turnSeq: pendingMessage.metadata.turnSeq } : {}),
},
}))
const contentRef = this.contentBlobStore.put(canonicalJson(body), {
@@ -879,14 +885,22 @@ class LocalWorkspaceMessageSubmission {
skillHintsByKind = new Map(), resumedGate = null }) {
const snapshotSourceMessageIds = [snapshot.messageId, ...snapshot.history.map(item => item.id)]
const frozenSnapshotHash = controller.orchestration.snapshotHash
const prompts = new Map(activeKinds.map(kind => [kind, v4Prompt({
const prompts = new Map(activeKinds.map(kind => [kind, manualReplyPrompt({
group: { ...group, ...snapshot.group },
kind,
phase: 'proposal',
role: kind === writerKind ? 'writer-proposal' : 'proposal',
snapshot,
skillHints: skillHintsByKind.get(kind) || [],
targetKinds,
taskText: snapshot.taskText,
history: snapshot.history,
skillHintsText: skillHintsPrompt(skillHintsByKind.get(kind) || []),
knowledgeBaseHintsText: knowledgeBaseHintsPrompt(
prepared?.knowledgeBaseHintsByKind?.get(kind) || [],
),
writer: kind === writerKind,
})]))
// Concurrent replies keep slot order in the timeline whatever order they
// finish in; failure notices take their slot's number too.
const reservedTurns = this.reserveTurnSeqs(group.id, threadRootId, activeKinds.length)
const turnSeqByKind = new Map(activeKinds.map((kind, index) => [kind, reservedTurns[index]]))
const pending = await Promise.all(activeKinds.map(async (kind) => {
const index = targetKinds.indexOf(kind)
const slot = slots[index]
@@ -923,6 +937,7 @@ class LocalWorkspaceMessageSubmission {
slotId: slot.slotId,
snapshotHash: frozenSnapshotHash,
sessionPolicy: 'frozen',
...(turnSeqByKind.get(kind) ? { turnSeq: turnSeqByKind.get(kind) } : {}),
promptOverride: prompts.get(kind),
contextPackId: controller.contextPackId,
snapshotSourceMessageIds,
@@ -965,6 +980,7 @@ class LocalWorkspaceMessageSubmission {
...(resumedGate?.agentKind === kind ? { resumedGate } : {}),
skillHints: skillHintsByKind.get(kind) || [],
knowledgeBaseHints: prepared?.knowledgeBaseHintsByKind?.get(kind) || [],
attachmentSnapshots: prepared?.attachments || [],
},
})
if (controller.signal.aborted) return { kind, index, stopped: true }
@@ -9,6 +9,11 @@ const {
terminalStatusPrefix,
} = require('./local-workspace-inputs.cjs')
function turnSeqMetadata(error) {
const turnSeq = Number(error?.turnSeq)
return Number.isSafeInteger(turnSeq) && turnSeq > 0 ? { turnSeq } : {}
}
class LocalWorkspaceRunMessages {
constructor(options) {
this.state = options.state
@@ -73,7 +78,7 @@ class LocalWorkspaceRunMessages {
kind,
threadRootId,
system,
{ trace, responseVersionRootId },
{ trace, responseVersionRootId, ...turnSeqMetadata(error) },
)
}
return { label, reason }
@@ -114,7 +119,7 @@ class LocalWorkspaceRunMessages {
key: interrupted ? 'system.agentInterrupted' : 'system.agentStopped',
params: { agent: label },
},
{ trace, responseVersionRootId },
{ trace, responseVersionRootId, ...turnSeqMetadata(error) },
)
return trace
}
@@ -10,8 +10,12 @@ const {
const AUTO_CONSENSUS_MARKER = /\[\[MELDWORK_CONSENSUS:(agree|continue)\]\]/gi
const AUTO_FINAL_CONSENSUS_MARKER = /(?:^|\r?\n)[ \t]*\[\[MELDWORK_CONSENSUS:(agree|continue)\]\][ \t]*$/i
const DEFAULT_RUN_SILENCE_WARNING_MS = 20 * 1000
const DEFAULT_RUN_AGENT_TIMEOUT_MS = 3 * 60 * 1000
const DEFAULT_RUN_AGENT_TOOL_TIMEOUT_MS = 5 * 60 * 1000
// Inactivity windows for one Agent turn. Real CLIs regularly think, run builds
// or wait on slow providers for several minutes; the former 3/5 minute limits
// killed Agents that were still working. A separate no-progress cap (three
// times the inactivity window) still stops CLIs that only emit heartbeats.
const DEFAULT_RUN_AGENT_TIMEOUT_MS = 10 * 60 * 1000
const DEFAULT_RUN_AGENT_TOOL_TIMEOUT_MS = 20 * 60 * 1000
const DEFAULT_RUN_ABORT_GRACE_MS = 2500
const DEFAULT_AUTO_ROUNDS = 6
const MAX_AUTO_ROUNDS = 10
@@ -1,4 +1,5 @@
const fs = require('node:fs')
const { atomicWritePrivateFile } = require('../security/private-file.cjs')
const path = require('node:path')
const { RUN_STATUSES } = require('../runs/run-ledger-records.cjs')
const { normalizeSessionMeta } = require('../runs/run-harness.cjs')
@@ -184,8 +185,17 @@ function loadWorkspaceState(storagePath) {
}
try {
const repliedAgentKinds = new Map()
for (const message of Array.isArray(parsed.messages) ? parsed.messages : []) {
if (message?.role !== 'agent' || typeof message.groupId !== 'string') continue
const kinds = repliedAgentKinds.get(message.groupId) || []
if (!kinds.includes(message.agentKind)) kinds.push(message.agentKind)
repliedAgentKinds.set(message.groupId, kinds)
}
const groups = parsed.groups
.map(normalizeLoadedGroup)
.map(group => normalizeLoadedGroup(group, {
recoveredAgentKinds: repliedAgentKinds.get(group?.id) || [],
}))
.filter(Boolean)
const groupIds = new Set(groups.map(group => group.id))
const messages = parsed.messages
@@ -238,11 +248,12 @@ function loadWorkspaceState(storagePath) {
}
}
// The whole workspace is rewritten on every save, synchronously on the main
// process. Compact JSON cuts the bytes written (and the time the UI is
// blocked) for long histories, and the private atomic writer fsyncs before
// the rename so a crash cannot leave a truncated file behind.
function saveWorkspaceState(storagePath, state) {
fs.mkdirSync(path.dirname(storagePath), { recursive: true })
const tempPath = `${storagePath}.tmp`
fs.writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
fs.renameSync(tempPath, storagePath)
atomicWritePrivateFile(storagePath, `${JSON.stringify(state)}\n`)
}
function durableWaitingRunSnapshots({ state, records, pendingGates, liveRunIds, liveGroupIds }) {
+33 -4
View File
@@ -131,7 +131,6 @@ class LocalWorkspace extends EventEmitter {
image: this.imageAttachmentLimitFn(kind),
}))
this.credentialStateFn = options.credentialState || (async () => ({ state: 'unknown', source: 'unverified' }))
this.sharedProviderReadyFn = options.sharedProviderReady || (() => false)
this.agentLabelFn = options.agentLabel || defaultAgentLabel
this.runSilenceWarningMs = Number.isFinite(options.runSilenceWarningMs)
&& options.runSilenceWarningMs > 0
@@ -193,6 +192,10 @@ class LocalWorkspace extends EventEmitter {
this.detectedAgents = []
this.preparingRuns = new Map()
this.activeRuns = new Map()
// Highest turn number handed out per group topic in this process. Saved
// messages are the durable source; reservations only cover turns that are
// dispatched but not written yet.
this.turnSeqReservations = new Map()
this.pendingV4ManualRecoveries = new Map()
this.pendingV4AutoRecoveries = new Map()
this.runCheckpointTimers = new Map()
@@ -204,7 +207,6 @@ class LocalWorkspace extends EventEmitter {
setDetectedAgents: agents => { this.detectedAgents = agents },
detectAgents: () => this.detectAgentsFn(),
credentialState: (...args) => this.credentialStateFn(...args),
sharedProviderReady: kind => this.sharedProviderReadyFn(kind),
attachmentSupport: kind => this.attachmentSupportFn(kind),
save: () => this.save(),
emitChanged: () => this.emitChanged(),
@@ -249,6 +251,7 @@ class LocalWorkspace extends EventEmitter {
terminalRetrySleep: options.terminalRetrySleep,
})
this.conversations = new LocalWorkspaceConversations({
reserveTurnSeq: (groupId, threadRootId) => this.reserveTurnSeqs(groupId, threadRootId, 1)[0],
state: () => this.state,
detectedAgents: () => this.detectedAgents,
save: () => this.save(),
@@ -268,7 +271,9 @@ class LocalWorkspace extends EventEmitter {
addMessage: (...args) => this.addMessage(...args),
})
this.agentInvocation = new LocalWorkspaceAgentInvocation({
reserveTurnSeq: (groupId, threadRootId) => this.reserveTurnSeqs(groupId, threadRootId, 1)[0],
state: () => this.state,
agentLabel: kind => this.agentLabel(kind),
detectedAgents: () => this.detectedAgents,
activeRuns: this.activeRuns,
runAgentTimeoutMs: this.runAgentTimeoutMs,
@@ -312,6 +317,7 @@ class LocalWorkspace extends EventEmitter {
attachmentSupport: (...args) => this.attachmentSupportFn(...args),
})
this.autoRunner = new LocalWorkspaceAutoRunner({
reserveTurnSeqs: (...args) => this.reserveTurnSeqs(...args),
state: () => this.state,
canWriteWorkspace: kind => agentRuntimeCapabilities(kind, {
agent: this.detectedAgents.find(agent => agent.kind === kind),
@@ -331,7 +337,6 @@ class LocalWorkspace extends EventEmitter {
recordAgentFailure: (...args) => this.recordAgentFailure(...args),
recordAgentInterruption: (...args) => this.recordAgentInterruption(...args),
addMessage: (...args) => this.addMessage(...args),
removeAgent: (...args) => this.conversations.removeAgent(...args),
commitV4AgentMessage: input => this.commitV4AgentMessage(input),
emitChanged: () => this.emitChanged(),
finishRun: (...args) => this.finishRun(...args),
@@ -354,6 +359,7 @@ class LocalWorkspace extends EventEmitter {
retrySleep: options.retrySleep,
})
this.messageSubmission = new LocalWorkspaceMessageSubmission({
reserveTurnSeqs: (...args) => this.reserveTurnSeqs(...args),
state: () => this.state,
detectedAgents: () => this.detectedAgents,
isShuttingDown: () => this.shuttingDown,
@@ -1178,7 +1184,9 @@ class LocalWorkspace extends EventEmitter {
&& continuation.snapshotHash === request.snapshotHash
&& slot?.agentRunId === source.agentRunId && slot.agentKind === source.kind
&& slot.operationId === request.operationId && slot.commitStatus === 'committed'
&& this.autoRunner.v4NaturalOwner(group, durable, durable.threadRootId) === source.kind)
&& this.autoRunner.v4NaturalOwner(
group, durable, durable.threadRootId, durable.orchestration?.activeKinds,
) === source.kind)
}
v4TaskDecisionResponses(runId) {
@@ -2432,6 +2440,22 @@ class LocalWorkspace extends EventEmitter {
)
}
// Reserves `count` consecutive dispatch-turn numbers for one group topic.
reserveTurnSeqs(groupId, threadRootId, count = 1) {
const amount = Math.max(0, Math.min(64, Math.floor(Number(count) || 0)))
if (!groupId || !threadRootId || !amount) return []
const key = `${groupId}\u0000${threadRootId}`
let cursor = this.turnSeqReservations.get(key) || 0
for (const message of this.state.messages) {
if (message.groupId === groupId && message.threadRootId === threadRootId
&& Number.isSafeInteger(message.turnSeq) && message.turnSeq > cursor) {
cursor = message.turnSeq
}
}
this.turnSeqReservations.set(key, cursor + amount)
return Array.from({ length: amount }, (_, index) => cursor + index + 1)
}
commitV4AgentMessage(input = {}) {
const messageId = String(input.messageId || '')
const existing = this.state.messages.find(message => message.id === messageId)
@@ -2461,6 +2485,11 @@ class LocalWorkspace extends EventEmitter {
...(input.metadata?.responseVersionRootId
? { responseVersionRootId: input.metadata.responseVersionRootId } : {}),
...(input.metadata?.trace ? { trace: input.metadata.trace } : {}),
...(input.metadata?.turnSeq
? { turnSeq: input.metadata.turnSeq }
: (input.threadRootId && group.conversationType !== 'direct'
? { turnSeq: this.reserveTurnSeqs(group.id, input.threadRootId, 1)[0] }
: {})),
})
if (!message || message.id !== messageId) throw new Error('LOCAL_RUN_COMMIT_INVALID')
const previousMessages = [...this.state.messages]
@@ -0,0 +1,44 @@
const { deliveryContract } = require('./prompt-contracts.cjs')
const { languageNote } = require('./natural-discussion-prompt.cjs')
// What each Agent is told when a user message goes to several Agents at once
// (manual group chat). Everyone answers on their own, at the same time, and
// nobody sees the other replies until they are all in. The user message is
// the task; the prompt does not ask for plans, work items or receipts.
function historyText(history) {
return (Array.isArray(history) ? history : [])
.map(item => `[${item.role}${item.agentKind ? `:${item.agentKind}` : ''}] ${item.text}`)
.join('\n')
}
function manualReplyPrompt({
group, kind, targetKinds = [], taskText, history = [], skillHintsText = '',
knowledgeBaseHintsText = '', writer = false,
}) {
const earlier = historyText(history)
const peers = targetKinds.filter(peer => peer !== kind)
const groupName = String(group?.name || '').trim().slice(0, 120)
const topic = String(group?.topic || '').trim().slice(0, 240)
return [
peers.length
? `You are one of several Agents answering the user's latest message in a Meldwork group chat. Everyone replies on their own at the same time; the others are ${peers.map(peer => `@${peer}`).join(', ')}. You cannot see their replies, so give your own complete answer and do not speak for them.`
: "You are answering the user's latest message in a Meldwork group chat.",
groupName ? `Group: ${groupName}${topic ? `\nTopic: ${topic}` : ''}` : '',
`User message:\n${taskText || '(none)'}`,
skillHintsText,
knowledgeBaseHintsText,
earlier
? `Earlier conversation in this group (reference only; verify it before relying on it):\n${earlier}`
: '',
writer
? [
'You are the Agent that may change files for this message. Make only the changes the user asked for within the authorized workspace, and verify the result.',
group?.allowWrite ? deliveryContract(group) : '',
].filter(Boolean).join('\n')
: 'This reply is read-only: do not modify files or other shared state. If a change is needed, describe it instead.',
'Answer naturally in Markdown. Do not output JSON, XML, protocol labels or hidden orchestration text.',
languageNote(taskText),
].filter(Boolean).join('\n\n')
}
module.exports = { manualReplyPrompt }
@@ -0,0 +1,145 @@
const { deliveryContract, responseLanguageFromText } = require('./prompt-contracts.cjs')
// The prompts of a natural discussion: Agents answer in their own words, and
// the only machine-readable part is one control receipt at the end of each
// discussion turn. The text is split into small named sections so each can be
// read, tested and changed on its own.
const PHASE_INSTRUCTIONS = Object.freeze({
proposal: 'Work independently on the user task and offer your own analysis, recommendations, and next steps.',
discussion: 'Continue from the available peer responses. Add useful evidence, challenge weak assumptions, resolve disagreements where possible, and advance the shared result.',
challenge: 'Review the available peer context as a subject-matter peer. Explain what you agree with, what is missing, and the clearest correction or direction.',
work: 'Complete the responsibility assigned to you. Report useful work, evidence, blockers, or handoffs in your own words.',
synthesis: 'Combine the available work into the clearest useful answer for the user. Resolve contradictions where possible and state remaining uncertainty plainly.',
verification: 'Independently check the proposed answer or deliverable. Report material defects, risks, or acceptance in your own words.',
})
function phaseInstruction(phase) {
return PHASE_INSTRUCTIONS[phase] || 'Address the current collaboration task directly.'
}
function groupLine(group) {
const name = String(group?.name || '').trim().slice(0, 120)
const topic = String(group?.topic || '').trim().slice(0, 240)
if (!name && !topic) return ''
return `Group: ${name || 'Meldwork group'}${topic ? `\nTopic: ${topic}` : ''}`
}
function languageNote(taskText) {
const language = responseLanguageFromText(taskText)
return language
? `Answer in ${language}, the language of the user task, unless the task asks for another language.`
: 'Answer in the language of the user task unless the task asks for another language.'
}
// The part of the prompt every natural turn starts with.
function naturalBrief({
group, phase, taskText, history = '', skillHintsText = '', knowledgeBaseHintsText = '',
}) {
return [
'You are participating in a local multi-agent discussion.',
groupLine(group),
`User task:\n${taskText || '(none)'}`,
skillHintsText,
knowledgeBaseHintsText,
history && history !== '(none)' ? `Relevant prior context:\n${history}` : '',
phaseInstruction(phase),
phase === 'proposal'
? 'This invocation is read-only. Its deliverable is your initial analysis and proposed next actions. Return that contribution when ready; any workspace changes belong to a later invocation that explicitly grants write permission. Do not attempt or delegate writes from this invocation, or wait here for write permission.'
: '',
phase === 'proposal'
? 'The other Agents are answering the same task independently right now. In the next round you will see their proposals and discuss the differences with them.'
: 'Respond to the other Agents directly: name the specific points you agree with, dispute weak ones with reasons or evidence, and extend what is missing. Build on their work instead of restating your earlier answer.',
phase === 'discussion'
? 'After concrete deliverables exist, review them for material gaps before accepting the result.'
: '',
phase === 'discussion'
? 'Answer the user naturally in Markdown. Keep orchestration instructions out of the answer. End every discussion turn with the control receipt described below; Meldwork removes it before the answer is shown.'
: 'Answer the user naturally in Markdown. Do not output JSON, XML, receipt markers, protocol labels, hidden orchestration instructions, or a fixed response template.',
languageNote(taskText),
'Do not claim another Agent performed work, and do not modify shared workspace state unless this turn explicitly grants that permission.',
].filter(Boolean).join('\n\n')
}
// Who may change files in this discussion.
function writerSection({ kind, writerKind, group }) {
if (!writerKind) return ''
if (kind !== writerKind) {
return `Workspace writes are assigned to @${writerKind}. Your invocation is read-only; request their contribution when a change is needed.`
}
return [
'You are the designated workspace writer for this task. Perform only writes required by the user task within the authorized workspace, and verify the resulting deliverables. Other participants are read-only.',
group?.allowWrite ? deliveryContract(group) : '',
].filter(Boolean).join('\n')
}
// What the delivery owner is responsible for, and what everyone else owes them.
function ownerSection({ kind, ownerKind }) {
if (!ownerKind) return ''
return [
`The current delivery owner is @${ownerKind}. Ownership starts with the first selected participant and can be handed off explicitly by that owner.`,
kind === ownerKind
? [
'You are responsible for judging progress against the original user task and the actual available deliverables. Decide whether to continue work, report a blocker, request a human decision, or declare completion. Silence from peers is not evidence of completion.',
'Before you declare completion, make sure the peers whose expertise covers the result have reviewed the actual deliverables. If one has not, ask them a concrete question and request them in nextKinds instead of completing.',
'In your final collaboration receipt use taskDecision.status completed, continue, blocked or needs-human to judge the whole task. A completed judgment requires actual deliverables. This is your task judgment, not human adoption or independent system verification.',
'To hand off responsibility, use status continue and add handoffTo with one exact selected Agent kind inside taskDecision. Do not hand off to an absent participant. Append nothing after the receipt.',
].join('\n')
: 'Respond to the delivery owner and the other peers on the specific points they raised. If you are blocked or need a human decision, explain why and use status blocked or needs-human; the delivery owner then decides how to proceed. Only the current delivery owner can decide task completion or hand off that responsibility.',
].join('\n')
}
// How the next speaker is chosen: by the scheduler (sequential) or by the
// Agent's own nextKinds request (agent-led).
function routingSection({ activeKinds, allowRouting }) {
if (allowRouting !== true) {
return 'Every selected Agent speaks in order each round, so you do not need to request peers. Finish this invocation instead of waiting for another participant; Meldwork runs the next one after you.'
}
const agentList = activeKinds.map(agentKind => `@${agentKind}`).join(', ')
return [
'Read the completed peer turns across recent rounds. Decide who should contribute next from their earlier evidence, unresolved questions, and work still needed; do not automatically reply only to whoever last mentioned you.',
`Address peers naturally inside your explanation: ${agentList}. Put each request beside its concrete question or requested contribution. Prose mentions are discussion only; they do not dispatch peers.`,
'Selected peers are external Meldwork participants; their @handles are not addresses for your native agent messaging or wait tools. Meldwork dispatches requested peers only after this invocation completes. Put the request in your final response and finish this invocation so they can run; do not wait inside this invocation for their reply.',
`In taskDecision.nextKinds list the selected Agent identifiers whose contribution you actually request next, chosen from ${JSON.stringify(activeKinds)}, or [] when none is needed. Use status continue for a nonempty list. Multiple kinds request a concurrent batch. Do not request yourself or absent participants. Historical, negated and explanatory mentions do not belong in nextKinds.`,
'Avoid repeating the same handoff or conclusions. Invite a peer who has not recently contributed when their earlier proposal can resolve the issue, and state what new evidence or decision you need.',
'List in nextKinds every peer whose answer, review or work you need next, and ask each of them a concrete question in your answer. Leave nextKinds empty only when no peer input would change the result; the delivery owner then judges overall task completion.',
].join('\n')
}
// The control receipt that ends every discussion turn.
function receiptSection({ kind, activeKinds, allowRouting }) {
const exampleNextKinds = allowRouting === true
? [activeKinds.find(peer => peer !== kind) || kind]
: []
return `After your natural answer append exactly one receipt, for example [[MELDWORK_COLLABORATION:{"summary":"concise result","taskDecision":{"status":"continue","reason":"what you accomplished and what remains","deliverables":[],"nextKinds":${JSON.stringify(exampleNextKinds)}}}]] while work or review remains, or a taskDecision of {"status":"completed","reason":"why the task is done","deliverables":["what was delivered"]} when the delivery owner judges the whole task done. Use blocked or needs-human with the reason when you cannot continue. This receipt is transport metadata: Meldwork removes it before displaying your answer. User requirements such as exact text, one line, JSON only, or no explanation apply to the visible answer before the receipt; preserve that requested answer exactly and still append the receipt separately. Do not explain the receipt in the visible answer. List actual deliverables when available. Only the current delivery owner judges the whole task; other participants report their contribution and may request peers with nextKinds. Merge any owner judgment or handoffTo into this same receipt. Append nothing after it.`
}
// One complete natural turn: the brief, what peers said so far, and the
// discussion-only sections.
function naturalDiscussionPrompt({
brief, group, kind, phase, activeKinds, transcript = '', humanResponsesText = '',
writerKind = '', ownerKind = '', allowRouting = false,
}) {
const discussion = phase === 'discussion'
return [
brief,
transcript ? `Completed peer turns:\n${transcript}` : '',
humanResponsesText
? `Human responses for this task (clarification, not a change to workspace permissions):\n${humanResponsesText}`
: '',
discussion ? writerSection({ kind, writerKind, group }) : '',
discussion ? ownerSection({ kind, ownerKind }) : '',
discussion ? routingSection({ activeKinds, allowRouting }) : '',
discussion ? receiptSection({ kind, activeKinds, allowRouting }) : '',
].filter(Boolean).join('\n\n')
}
module.exports = {
languageNote,
naturalBrief,
naturalDiscussionPrompt,
ownerSection,
receiptSection,
routingSection,
writerSection,
}
@@ -0,0 +1,52 @@
// Instructions every Agent prompt shares: how to answer, and what a delivered
// file means. They are plain functions of the conversation so each prompt
// builder can include exactly the parts it needs.
function responseLanguageFromText(text) {
const source = String(text || '').normalize('NFKC')
const han = (source.match(/[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/g) || []).length
const kana = (source.match(/[\u3040-\u30ff]/g) || []).length
const hangul = (source.match(/[\uac00-\ud7af]/g) || []).length
const cyrillic = (source.match(/[\u0400-\u052f]/g) || []).length
const arabic = (source.match(/[\u0600-\u06ff]/g) || []).length
const latin = (source.match(/[A-Za-z]/g) || []).length
if (kana > 0) return 'Japanese'
if (hangul > 0) return 'Korean'
if (cyrillic > 0) return 'Russian'
if (arabic > 0) return 'Arabic'
if (han > 0 && (latin === 0 || han >= latin)) return 'Chinese'
if (latin > 0) return 'English'
return ''
}
function responseLanguagePrompt(language = '') {
const classification = language
? `The latest user message is written primarily in ${language}.`
: 'The latest user message language was not confidently classified; infer it from the message body only.'
return [
'Response language contract:',
'Always answer entirely in the language of the latest user message unless that message explicitly requests another language.',
classification,
'If the latest user message is English, answer entirely in English. If it is Chinese, answer entirely in Chinese.',
'Do not infer response language from the app UI locale, operating-system locale, Agent name, attachment filenames, or earlier conversation turns.',
'Do not translate or switch languages unless the latest user message explicitly asks for it.',
].join('\n')
}
function deliveryContract(group) {
return group?.allowWrite
? [
'Workspace access: You may create and edit files in the conversation working directory. When the user requests a deliverable, execute the work instead of returning only a plan.',
'Deliverable capture contract: Save or copy each final document, code file, diff, structured-data file, media file, or bundle into .meldwork-output/ in the conversation working directory.',
'Only files created or changed during this run are captured as durable outputs. Do not claim a deliverable exists until the real file has been written there.',
'When the user asks for an image, audio, or video, do not claim it was generated unless a real file exists.',
'Supported chat attachments include common images, media, documents, source code, configuration text, and archives. Other recognized outputs remain durable Artifacts.',
].join('\n')
: [
'This conversation is read-only. Do not claim that a media file was generated or delivered because no writable output can be attached.',
'If the user requests generated media, explain that workspace write access must be enabled before a real file can be delivered.',
].join('\n')
}
module.exports = { deliveryContract, responseLanguageFromText, responseLanguagePrompt }
@@ -326,3 +326,26 @@ test('preserves the existing four-media attachment import limit', (t) => {
const imported = importAgentOutputs({ workdir, baseline, startedAt }, store)
assert.deepEqual(imported.map(item => item.name), ['e.png', 'd.png', 'c.png', 'b.png'])
})
test('reported paths import only files written during the run, outside input staging', (t) => {
const { workdir, store } = fixture(t)
const existing = path.join(workdir, 'README.png')
fs.writeFileSync(existing, PNG)
const old = new Date(Date.now() - 60 * 60 * 1000)
fs.utimesSync(existing, old, old)
const staged = path.join(workdir, '.meldwork-input', '.run-1', 'input.png')
fs.mkdirSync(path.dirname(staged), { recursive: true })
fs.writeFileSync(staged, PNG)
const baseline = captureAgentOutputState(workdir)
const startedAt = Date.now()
const written = path.join(workdir, 'charts', 'result chart.png')
fs.mkdirSync(path.dirname(written), { recursive: true })
fs.writeFileSync(written, PNG)
const imported = importAgentOutputs({
workdir, baseline, startedAt, reportedPaths: [existing, staged, written],
}, store)
// A file the Agent only mentioned (or the user's staged input) is not an output.
assert.deepEqual(imported.map(item => item.name), ['result chart.png'])
})
@@ -0,0 +1,55 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const { automaticPermissionResponse } = require('../../src/agents/agent-permission-policy.cjs')
const options = [
{ optionId: 'allow-always', kind: 'allow_always', name: 'Always' },
{ optionId: 'allow', kind: 'allow_once', name: 'Allow' },
{ optionId: 'reject', kind: 'reject_once', name: 'Reject' },
]
test('read-only turns approve only tools without side effects', () => {
for (const kind of ['read', 'search', 'fetch', 'think']) {
assert.deepEqual(
automaticPermissionResponse({ toolCall: { kind }, options }, { allowWrite: false }),
{ status: 'approved', optionId: 'allow' },
kind,
)
}
for (const kind of ['edit', 'delete', 'move', 'execute', 'switch_mode', 'other', undefined]) {
assert.deepEqual(
automaticPermissionResponse({ toolCall: { kind }, options }, { allowWrite: false }),
{ status: 'rejected', optionId: 'reject' },
String(kind),
)
}
})
test('a refused request without a reject option is answered with null instead of the user gate', () => {
assert.equal(automaticPermissionResponse({
toolCall: { kind: 'execute' },
options: [{ optionId: 'allow', kind: 'allow_once' }],
}, { allowWrite: false }), null)
})
test('writable turns approve everything the Agent offers, preferring a one-time allow', () => {
assert.deepEqual(
automaticPermissionResponse({ toolCall: { kind: 'execute' }, options }, { allowWrite: true }),
{ status: 'approved', optionId: 'allow' },
)
assert.deepEqual(
automaticPermissionResponse({
toolCall: { kind: 'edit' },
options: [{ optionId: 'only', kind: 'something_else' }],
}, { allowWrite: true }),
{ status: 'approved', optionId: 'only' },
)
})
test('with nothing to choose the caller must ask the user', () => {
assert.equal(
automaticPermissionResponse({ toolCall: { kind: 'read' }, options: [] }, { allowWrite: false }),
undefined,
)
assert.equal(automaticPermissionResponse(null, { allowWrite: true }), undefined)
})
@@ -0,0 +1,86 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const adapters = require('../../../src/agents/cli/cli-adapters.cjs')
const { executable, within } = require('../../support/cli-adapters-test-helpers.cjs')
function noisyHermes(directory) {
return executable(directory, 'hermes-noisy-acp.cjs', `
const fs = require('node:fs')
const readline = require('node:readline')
if (process.argv[2] !== 'acp') {
process.stdout.write('legacy fallback')
process.exit(0)
}
const input = readline.createInterface({ input: process.stdin })
const send = value => process.stdout.write(JSON.stringify(value) + '\\n')
process.stdout.write('Hermes 9.9.9 starting up...\\n')
input.on('line', (line) => {
let message
try { message = JSON.parse(line) } catch { return }
const sessionId = message.params?.sessionId || 'noisy-session'
if (message.id >= 9001 && message.id <= 9004 && !message.method) {
fs.appendFileSync(process.env.MELDWORK_TEST_UNKNOWN_RESPONSE, JSON.stringify(message) + '\\n')
return
}
if (message.method === 'initialize') {
send({ jsonrpc: '2.0', id: message.id, result: { protocolVersion: 1 } })
} else if (message.method === 'session/new') {
send({ jsonrpc: '2.0', id: message.id, result: { sessionId } })
} else if (message.method === 'session/set_mode') {
send({ jsonrpc: '2.0', id: message.id, result: {} })
} else if (message.method === 'session/prompt') {
process.stdout.write('WARNING: update available (not protocol traffic)\\n')
send({ jsonrpc: '2.0', method: '_vendor/progress', params: { done: 1 } })
send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'brand_new_update_kind', payload: 1 } } })
send({ jsonrpc: '2.0', id: 9001, method: 'vendor/unknown_request', params: { sessionId } })
// Methods the client never advertised: a conforming Agent does not call
// them, a non-conforming one must get an error, never a made-up success.
send({ jsonrpc: '2.0', id: 9002, method: 'fs/read_text_file', params: { sessionId, path: '/etc/hosts' } })
send({ jsonrpc: '2.0', id: 9003, method: 'fs/write_text_file', params: { sessionId, path: '/tmp/x', content: 'x' } })
send({ jsonrpc: '2.0', id: 9004, method: 'terminal/create', params: { sessionId, command: 'id' } })
send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: {
sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'acp answer despite the noise' },
} } })
setTimeout(() => send({ jsonrpc: '2.0', id: message.id, result: { stopReason: 'end_turn' } }), 50)
}
})
`)
}
test('ACP turns survive banners, unknown notifications, new update kinds and unknown requests', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-acp-tolerance-'))
t.after(async () => {
await adapters.shutdownAcpSessionRuntime?.()
fs.rmSync(directory, { recursive: true, force: true })
})
const unknownResponse = path.join(directory, 'unknown-responses.jsonl')
const agent = { kind: 'hermes', executable: noisyHermes(directory), name: 'Hermes' }
const result = await within(adapters.runAgent(agent, 'say something', directory, {
acpPersistenceKey: 'workspace:hermes:tolerance',
env: { MELDWORK_TEST_UNKNOWN_RESPONSE: unknownResponse },
}), 10000)
assert.equal(result.text, 'acp answer despite the noise')
// Every request the client does not support is answered with an error, so
// the Agent is never left waiting and never receives a made-up success.
const deadline = Date.now() + 5000
let responses = []
while (Date.now() < deadline) {
try {
responses = fs.readFileSync(unknownResponse, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line))
} catch { /* not written yet */ }
if (responses.length >= 4) break
await new Promise(resolve => setTimeout(resolve, 20))
}
assert.deepEqual(responses.map(response => response.id).sort(), [9001, 9002, 9003, 9004])
for (const response of responses) {
assert.equal('result' in response, false, `request ${response.id} must not succeed`)
assert.equal(typeof response.error?.code, 'number')
}
assert.equal(responses.find(response => response.id === 9001).error.code, -32601)
})
@@ -30,7 +30,7 @@ const {
searchPath,
} = require('../../../src/agents/cli/cli-adapters.cjs')
const { resolveConnectorEventProfile } = require('../../../src/agents/cli/cli-event-profiles.cjs')
const { managedOpenClawOptions } = require('../../../src/agents/cli/openclaw-runtime.cjs')
const { nativeOpenClawOptions } = require('../../../src/agents/cli/openclaw-runtime.cjs')
const {
executable,
readJsonWhenReady,
@@ -134,14 +134,19 @@ input.on('line', (line) => {
}
function signedOpenClawRuntime(storageRoot, workdir, sessionRef) {
return managedOpenClawOptions({
return nativeOpenClawOptions({
storageRoot,
workdir,
sessionRef,
provider: {
OPENAI_API_KEY: 'adapter-openclaw-key',
OPENAI_BASE_URL: 'https://api.example.com/v1',
OPENAI_MODEL: 'adapter-model',
runtime: {
model: 'native/model',
provider: {
id: 'native',
baseUrl: 'https://native.example.com/v1',
api: 'openai-completions',
apiKey: 'adapter-openclaw-key',
model: { id: 'model', name: 'Native Model', input: ['text'] },
},
},
})
}
@@ -384,7 +389,12 @@ input.on('line', (line) => {
),
'new|plan|cancelled|first prompt|[path]',
)
assert.deepEqual(createdEvents.slice(0, 2).map(event => ({
// Thinking surfaces only as a text-free reasoning status event.
assert.equal(createdEvents.some(event => (
event.type === 'reasoning_summary' && event.status === 'running' && !event.summary
)), true)
const createdToolEvents = createdEvents.filter(event => event.type !== 'reasoning_summary')
assert.deepEqual(createdToolEvents.slice(0, 2).map(event => ({
id: event.id,
type: event.type,
status: event.status,
@@ -393,8 +403,8 @@ input.on('line', (line) => {
{ id: 'tool-1', type: 'tool_start', status: 'running', title: 'search' },
{ id: 'tool-1', type: 'tool_result_summary', status: 'completed', title: 'search' },
])
assert.equal(createdEvents[0].summary, 'search: [operation hidden]')
assert.match(createdEvents[1].detail, /Output: 1 line, 19 bytes/)
assert.equal(createdToolEvents[0].summary, 'search: [operation hidden]')
assert.match(createdToolEvents[1].detail, /Output: 1 line, 19 bytes/)
assert.doesNotMatch(
JSON.stringify(createdEvents),
/PRIVATE_CHAIN_OF_THOUGHT|command|rg |Users|private tool output|stderr/i,
@@ -849,7 +859,7 @@ input.on('line', (line) => {
)
})
test('Kimi ACP rejects unsafe protocol input without logging secret-bearing messages', async (t) => {
test('Kimi ACP fails closed on malformed JSON and ignores unknown traffic without logging secret-bearing messages', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-kimi-acp-invalid-'))
const secret = 'private-kimi-transport-secret'
const cli = executable(directory, 'kimi-acp-invalid.cjs', `
@@ -868,10 +878,23 @@ input.on('line', (line) => {
if (process.env.MELDWORK_TEST_CASE === 'malformed') {
process.stdout.write('{"secret":"' + process.env.MELDWORK_TEST_SECRET + '"\\n')
} else {
// Traffic this client does not know: a banner, a notification and a
// request, each carrying the secret. None of it may fail the turn or
// reach a log.
process.stdout.write('banner ' + process.env.MELDWORK_TEST_SECRET + '\\n')
send({
jsonrpc: '2.0', method: 'unsupported/client_method',
params: { secret: process.env.MELDWORK_TEST_SECRET },
})
send({
jsonrpc: '2.0', id: 77, method: 'unsupported/client_request',
params: { secret: process.env.MELDWORK_TEST_SECRET },
})
send({ jsonrpc: '2.0', method: 'session/update', params: {
sessionId: 'kimi-invalid-session',
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'carried on' } },
} })
setTimeout(() => send({ jsonrpc: '2.0', id: message.id, result: { stopReason: 'end_turn' } }), 30)
}
}
})
@@ -879,27 +902,34 @@ input.on('line', (line) => {
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const logged = []
const originalConsoleError = console.error
console.error = (...args) => logged.push(args.map(String).join(' '))
console.error = (...args) => logged.push(args.map(arg => (
typeof arg === 'string' ? arg : JSON.stringify(arg)
)).join(' '))
t.after(() => { console.error = originalConsoleError })
for (const testCase of ['malformed', 'unsupported']) {
await assert.rejects(
runAgent(
{ kind: 'kimi', executable: cli, name: 'Kimi' },
'validate transport',
directory,
{ env: { MELDWORK_TEST_CASE: testCase, MELDWORK_TEST_SECRET: secret } },
),
(error) => {
assert.equal(error.message, 'LOCAL_AGENT_PROCESS_FAILED')
assert.equal(error.diagnostic.includes(secret), false)
assert.equal(Object.prototype.propertyIsEnumerable.call(error, 'diagnostic'), false)
return true
},
)
}
await assert.rejects(
runAgent(
{ kind: 'kimi', executable: cli, name: 'Kimi' },
'validate transport',
directory,
{ env: { MELDWORK_TEST_CASE: 'malformed', MELDWORK_TEST_SECRET: secret } },
),
(error) => {
assert.equal(error.message, 'LOCAL_AGENT_PROCESS_FAILED')
assert.equal(error.diagnostic.includes(secret), false)
assert.equal(Object.prototype.propertyIsEnumerable.call(error, 'diagnostic'), false)
return true
},
)
assert.deepEqual(logged, [])
const survived = await runAgent(
{ kind: 'kimi', executable: cli, name: 'Kimi' },
'validate transport',
directory,
{ env: { MELDWORK_TEST_CASE: 'unknown', MELDWORK_TEST_SECRET: secret } },
)
assert.equal(survived.text, 'carried on')
assert.equal(logged.some(entry => entry.includes(secret)), false)
})
test('Kimi ACP bounds unframed input, cumulative reply text, and total protocol traffic', async (t) => {
@@ -919,7 +949,7 @@ input.on('line', (line) => {
} else if (message.method === 'session/prompt') {
const testCase = process.env.MELDWORK_TEST_CASE
if (testCase === 'line') {
process.stdout.write('x'.repeat(1024 * 1024 + 1))
process.stdout.write('x'.repeat(4 * 1024 * 1024 + 1))
return
}
const update = testCase === 'reply' ? 'agent_message_chunk' : 'agent_thought_chunk'
@@ -1033,24 +1063,14 @@ test('Claude uses partial stream JSON and resumes its native session', () => {
assert.equal(spec.promptArg, true)
})
test('Claude selected Provider overrides native cloud selection without credentials in argv', () => {
test('Claude keeps its native credentials without a settings override', () => {
const spec = invocation('claude', '/tmp/claude', '/tmp/work', 'native-session', {
provider: { id: 'anthropic', model: 'selected-model', baseUrl: 'https://selected.example' },
env: { ANTHROPIC_API_KEY: 'selected-secret-key' },
sandbox: 'read-only',
})
const settings = JSON.parse(spec.args[spec.args.indexOf('--settings') + 1])
assert.equal(settings.env.ANTHROPIC_BASE_URL, 'https://selected.example')
assert.equal(settings.env.ANTHROPIC_API_KEY, '')
assert.equal(settings.env.ANTHROPIC_AUTH_TOKEN, '')
assert.equal(settings.env.CLAUDE_CODE_OAUTH_TOKEN, '')
assert.equal(settings.env.ANTHROPIC_CUSTOM_HEADERS, '')
assert.match(settings.apiKeyHelper, /MELDWORK_PROVIDER_API_KEY/)
for (const flag of ['BEDROCK', 'VERTEX', 'FOUNDRY', 'MANTLE']) {
assert.equal(settings.env[`CLAUDE_CODE_USE_${flag}`], '0')
}
assert.equal(spec.args[spec.args.indexOf('--model') + 1], 'selected-model')
assert.equal(spec.args.includes('--settings'), false)
assert.equal(spec.args.includes('--model'), false)
assert.equal(spec.args[spec.args.indexOf('--resume') + 1], 'native-session')
assert.equal(JSON.stringify(spec.args).includes('selected-secret-key'), false)
assert.equal(spec.args[spec.args.indexOf('--permission-mode') + 1], 'plan')
})
test('MiMo uses ACP by default and declares its JSON setup fallback', () => {
@@ -1230,14 +1250,13 @@ test('Qwen uses plan mode by default and auto-edit after authorization', () => {
])
})
test('Qwen selects OpenAI auth when the shared provider is enabled', () => {
test('Qwen keeps its native auth without a provider override', () => {
const spec = invocation('qwen', '/tmp/qwen', '/tmp/work', '', {
provider: { id: 'openai', model: 'glm' },
sandbox: 'read-only',
})
assert.deepEqual(spec.args, [
'--output-format', 'stream-json', '--include-partial-messages',
'--approval-mode', 'plan',
'--auth-type', 'openai', '--model', 'glm',
])
})
@@ -5,7 +5,7 @@ const os = require('node:os')
const path = require('node:path')
const { EventEmitter } = require('node:events')
const { PassThrough } = require('node:stream')
const { probeAgentCapabilities } = require('../../../src/agents/cli/cli-discovery.cjs')
const { launchEnvironment, probeAgentCapabilities } = require('../../../src/agents/cli/cli-discovery.cjs')
const {
detectAgents,
imageAttachmentLimit,
@@ -30,6 +30,7 @@ const {
readWhenReady,
waitForExit,
within,
workBuddyInstall,
} = require('../../support/cli-adapters-test-helpers.cjs')
test('search path includes common user CLI locations', () => {
@@ -38,6 +39,45 @@ test('search path includes common user CLI locations', () => {
assert.match(searchPath(), /\.mimocode\/bin/)
})
test('a WorkBuddy build that ships only the lite bundle is probed through it', async (t) => {
const help = '--print --output-format --permission-mode --max-turns --resume json plan acceptEdits'
const probe = async (kind, launcher) => {
let probeEnv
const result = await probeAgentCapabilities(kind, launcher, {
env: {},
execFileFn: async (_command, _args, options) => {
probeEnv = options.env
return { stdout: help }
},
})
return { probeEnv, result }
}
const liteOnly = workBuddyInstall(t, ['codebuddy-lite-wb.mjs'])
const withHeadless = workBuddyInstall(t, ['codebuddy-lite-wb.mjs', 'codebuddy-headless.js', 'codebuddy.js'])
const lite = await probe('workbuddy', liteOnly)
assert.equal(lite.probeEnv.CODEBUDDY_FORCE_LITE_WB_BUNDLE, '1')
assert.equal(lite.result.compatibilityState, 'compatible')
assert.equal(
Object.hasOwn((await probe('workbuddy', withHeadless)).probeEnv, 'CODEBUDDY_FORCE_LITE_WB_BUNDLE'),
false,
)
assert.equal(
Object.hasOwn((await probe('claude', liteOnly)).probeEnv, 'CODEBUDDY_FORCE_LITE_WB_BUNDLE'),
false,
)
assert.deepEqual(launchEnvironment('workbuddy', path.join(os.tmpdir(), 'meldwork-no-such-cli')), {})
assert.deepEqual(launchEnvironment('workbuddy', ''), {})
if (process.platform !== 'win32') {
const linkDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-workbuddy-link-'))
t.after(() => fs.rmSync(linkDirectory, { recursive: true, force: true }))
const link = path.join(linkDirectory, 'codebuddy')
fs.symlinkSync(liteOnly, link)
assert.deepEqual(launchEnvironment('workbuddy', link), { CODEBUDDY_FORCE_LITE_WB_BUNDLE: '1' })
}
})
test('capability timeouts retry once and remain inconclusive when both attempts time out', async () => {
for (const recover of [true, false]) {
const timeouts = []
+152 -24
View File
@@ -26,7 +26,25 @@ const {
searchPath,
} = require('../../../src/agents/cli/cli-adapters.cjs')
const { resolveConnectorEventProfile } = require('../../../src/agents/cli/cli-event-profiles.cjs')
const { managedOpenClawOptions } = require('../../../src/agents/cli/openclaw-runtime.cjs')
const { nativeOpenClawOptions } = require('../../../src/agents/cli/openclaw-runtime.cjs')
function openClawAdapterRuntime(storageRoot, workdir, sessionRef = '') {
return nativeOpenClawOptions({
storageRoot,
workdir,
...(sessionRef ? { sessionRef } : {}),
runtime: {
model: 'native/model',
provider: {
id: 'native',
baseUrl: 'https://native.example.com/v1',
api: 'openai-completions',
apiKey: 'adapter-openclaw-key',
model: { id: 'model', name: 'Native Model', input: ['text'] },
},
},
})
}
const {
executable,
readJsonWhenReady,
@@ -521,6 +539,45 @@ process.exitCode = 1
assert.equal(events.some(event => event.type === 'answer_delta'), true)
})
for (const exitCode of [0, 1]) {
test(`WorkBuddy authentication failures classify as auth-required with exit code ${exitCode}`, async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), `meldwork-workbuddy-auth-${exitCode}-`))
const cli = executable(directory, 'workbuddy-auth-required.cjs', `
process.stdout.write(JSON.stringify({
type: 'system', subtype: 'init', session_id: 'workbuddy-auth-session',
apiKeySource: 'www.workbuddy.cn',
}) + '\\n')
process.stdout.write(JSON.stringify({
type: 'assistant', session_id: 'workbuddy-auth-session',
message: { role: 'assistant', content: [
{ type: 'text', text: 'Authentication required. Please use /login command to sign in to your account' },
] },
}) + '\\n')
process.stdout.write(JSON.stringify({
type: 'result', subtype: 'error_during_execution', is_error: true,
session_id: 'workbuddy-auth-session',
errors: ['Authentication required. Please use /login command to sign in to your account'],
}) + '\\n')
process.exitCode = ${exitCode}
`)
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
await assert.rejects(
runAgent(
{ kind: 'workbuddy', executable: cli, name: 'WorkBuddy' },
'hello',
directory,
{ },
),
(error) => {
assert.equal(error.code, 'LOCAL_AGENT_AUTH_REQUIRED')
assert.match(error.diagnostic || '', /Authentication required/u)
return true
},
)
})
}
test('Gemini profile state retains one real tool lifecycle id and summary', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-gemini-profile-state-'))
const cli = executable(directory, 'gemini-profile-state.cjs', `
@@ -1082,7 +1139,11 @@ input.on('line', (line) => {
(answer, event) => event.replace === true ? event.delta : answer + event.delta,
'',
), 'current answer')
assert.deepEqual(events.slice(0, 3).map(event => [event.type, event.status]), [
assert.equal(events.some(event => (
event.type === 'reasoning_summary' && event.status === 'running' && !event.summary
)), true)
assert.deepEqual(events.filter(event => event.type !== 'reasoning_summary')
.slice(0, 3).map(event => [event.type, event.status]), [
['plan', 'running'],
['tool_start', 'running'],
['tool_result_summary', 'completed'],
@@ -1592,14 +1653,30 @@ test('Pi uses non-interactive JSON flags and keeps provider settings out of argu
assert.equal(typeof parsePiOutput, 'function')
})
test('WorkBuddy write mode accepts edits without skipping every permission check', () => {
const writable = invocation('workbuddy', '/tmp/codebuddy', '/tmp', '', { sandbox: 'workspace-write' })
assert.equal(writable.args[writable.args.indexOf('--permission-mode') + 1], 'acceptEdits')
assert.equal(writable.args.includes('--dangerously-skip-permissions'), false)
const readOnly = invocation('workbuddy', '/tmp/codebuddy', '/tmp', '', { sandbox: 'read-only' })
assert.equal(readOnly.args[readOnly.args.indexOf('--permission-mode') + 1], 'plan')
})
test('Hermes forwards one image and rejects additional images instead of dropping them', () => {
const first = path.resolve('diagram-one.png')
const second = path.resolve('diagram-two.png')
// ACP Hermes keeps its session and receives the image as a content block.
const spec = invocation('hermes', '/tmp/hermes', '/tmp', '', {
attachments: [first],
})
assert.deepEqual(spec.args, [
'chat', '--quiet', '--image', first, '--query',
assert.deepEqual(spec.args, ['acp'])
assert.deepEqual(spec.imageAttachments, [first])
// A conversation already on the legacy transport passes the image argument.
const legacy = invocation('hermes', '/tmp/hermes', '/tmp', 'hermes-legacy-session', {
attachments: [first],
sessionTransport: 'legacy',
})
assert.deepEqual(legacy.args, [
'chat', '--quiet', '--resume', 'hermes-legacy-session', '--image', first, '--query',
])
assert.throws(
() => invocation('hermes', '/tmp/hermes', '/tmp', '', {
@@ -1969,16 +2046,7 @@ process.stdout.write(JSON.stringify({
}))
`)
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const runtime = managedOpenClawOptions({
storageRoot: directory,
workdir,
sessionRef: 'agent:main:adapter-workspace',
provider: {
OPENAI_API_KEY: 'adapter-openclaw-key',
OPENAI_BASE_URL: 'https://api.example.com/v1',
OPENAI_MODEL: 'adapter-model',
},
})
const runtime = openClawAdapterRuntime(directory, workdir, 'agent:main:adapter-workspace')
const events = []
const result = await runAgent(
@@ -2053,16 +2121,7 @@ process.stdout.write(JSON.stringify({
`)
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const stableSessionRef = 'agent:main:openclaw-acp-fallback'
const runtime = managedOpenClawOptions({
storageRoot: directory,
workdir,
sessionRef: stableSessionRef,
provider: {
OPENAI_API_KEY: 'adapter-openclaw-key',
OPENAI_BASE_URL: 'https://api.example.com/v1',
OPENAI_MODEL: 'adapter-model',
},
})
const runtime = openClawAdapterRuntime(directory, workdir, stableSessionRef)
const result = await runAgent(
{ kind: 'openclaw', executable: cli, name: 'OpenClaw' },
@@ -2090,3 +2149,72 @@ process.stdout.write(JSON.stringify({
'--json',
])
})
test('MiMo and OpenCode keep running after a step that ended in tool calls', async (t) => {
for (const kind of ['mimo', 'opencode']) {
await t.test(kind, async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), `meldwork-${kind}-multistep-`))
const cli = executable(directory, `${kind}-multistep.cjs`, `
const send = value => process.stdout.write(JSON.stringify(value) + '\\n')
send({ type: 'step_start', sessionID: '${kind}-multistep-session', part: { type: 'step-start' } })
send({ type: 'text', sessionID: '${kind}-multistep-session', part: { type: 'text', text: 'Reading the files first. ' } })
// The model asked for tools: this step is over, the run is not.
send({ type: 'step_finish', sessionID: '${kind}-multistep-session', part: { type: 'step-finish', reason: 'tool-calls' } })
setTimeout(() => {
send({ type: 'step_start', sessionID: '${kind}-multistep-session', part: { type: 'step-start' } })
send({ type: 'text', sessionID: '${kind}-multistep-session', part: { type: 'text', text: 'Final answer after the tools.' } })
send({ type: 'step_finish', sessionID: '${kind}-multistep-session', part: { type: 'step-finish', reason: 'stop' } })
}, 900)
`)
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const result = await within(runAgent(
{ kind, executable: cli, name: kind },
'inspect and answer',
directory,
{ loadAcpSdkFn: async () => { throw new Error('ACP SDK unavailable') } },
), 15000)
assert.match(result.text, /Reading the files first\./)
assert.match(result.text, /Final answer after the tools\./)
assert.equal(result.outcome, 'completed')
})
}
})
test('a CLI that exits before reading its prompt reports its own error, not a broken pipe', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-codex-early-exit-'))
// Codex reads the prompt from stdin. A CLI that rejects the run at startup
// exits at once; a prompt larger than the pipe buffer then hits EPIPE.
const cli = executable(directory, 'codex-early-exit.cjs', `
process.stderr.write('Not logged in. Please run codex login.\\n')
process.exit(1)
`)
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const prompt = `Review this document.\n${'lorem ipsum '.repeat(300000)}`
await assert.rejects(
within(runAgent({ kind: 'codex', executable: cli, name: 'Codex' }, prompt, directory, {}), 15000),
(error) => {
assert.equal(error.code, 'LOCAL_AGENT_AUTH_REQUIRED')
assert.notEqual(error.message, 'LOCAL_AGENT_SEND_FAILED')
return true
},
)
})
test('a CLI that stays alive without reading its prompt still fails as a send failure', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-codex-no-reader-'))
const cli = executable(directory, 'codex-no-reader.cjs', `
// Closes its input without reading it, then idles.
require('node:fs').closeSync(0)
setInterval(() => {}, 1000)
`)
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const prompt = `Review this document.\n${'lorem ipsum '.repeat(300000)}`
await assert.rejects(
within(runAgent({ kind: 'codex', executable: cli, name: 'Codex' }, prompt, directory, {}), 20000),
error => error.message === 'LOCAL_AGENT_SEND_FAILED',
)
})
@@ -1,102 +0,0 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const http = require('node:http')
const os = require('node:os')
const path = require('node:path')
const { execFile } = require('node:child_process')
const { promisify } = require('node:util')
const test = require('node:test')
const { invocation, runAgent } = require('../../../src/agents/cli/cli-adapters.cjs')
const { providerOptionsFor } = require('../../../src/providers/provider-options.cjs')
test('Claude credential helper reads a secret as data without shell expansion', {
skip: process.platform === 'win32',
}, async () => {
const secret = 'test-$(exit 37)-`exit 38`-"quoted"-$HOME-&-secret'
const spec = invocation('claude', '/tmp/claude', '/tmp', '', {
provider: { id: 'anthropic', model: 'test-model', baseUrl: 'https://example.com' },
platform: 'linux',
})
const settings = JSON.parse(spec.args[spec.args.indexOf('--settings') + 1])
const result = await promisify(execFile)('/bin/sh', ['-c', settings.apiKeyHelper], {
env: { MELDWORK_PROVIDER_API_KEY: secret }, timeout: 5000,
})
assert.equal(result.stdout, secret)
assert.equal(JSON.stringify(spec.args).includes(secret), false)
})
test('Claude Windows credential helper does not interpolate the key into cmd syntax', () => {
const spec = invocation('claude', 'C:\\claude.exe', 'C:\\work', '', {
provider: { id: 'anthropic', model: 'test-model' }, platform: 'win32',
})
const settings = JSON.parse(spec.args[spec.args.indexOf('--settings') + 1])
assert.match(settings.apiKeyHelper, /GetEnvironmentVariable\('MELDWORK_PROVIDER_API_KEY'\)/)
assert.doesNotMatch(settings.apiKeyHelper, /%MELDWORK_PROVIDER_API_KEY%/)
})
test('native Claude uses the selected Provider despite conflicting native settings', {
skip: !process.env.MELDWORK_TEST_CLAUDE_EXECUTABLE,
timeout: 40000,
}, async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-claude-provider-'))
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
const requests = []
const selectedKey = 'selected-validation-key'
async function endpoint(label) {
const server = http.createServer((req, res) => {
req.resume()
if (req.method === 'HEAD') { res.writeHead(200); res.end(); return }
requests.push({ label, selectedKey: req.headers['x-api-key'] === selectedKey
|| req.headers.authorization === `Bearer ${selectedKey}` })
res.writeHead(200, { 'Content-Type': 'text/event-stream' })
for (const event of [
{ type: 'message_start', message: {
id: 'msg_local', type: 'message', role: 'assistant', model: 'claude-sonnet-4-6',
content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0 },
} },
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'LOCAL_PROVIDER_OK' } },
{ type: 'content_block_stop', index: 0 },
{ type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } },
{ type: 'message_stop' },
]) res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`)
res.end()
})
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
t.after(() => new Promise(resolve => { server.close(resolve); server.closeAllConnections() }))
return `http://127.0.0.1:${server.address().port}`
}
const selected = await endpoint('selected')
const native = await endpoint('native')
const config = path.join(root, '.claude')
fs.mkdirSync(config)
const filename = path.join(config, 'settings.json')
const settings = JSON.stringify({
apiKeyHelper: 'exit 39', permissions: { deny: ['Bash(*)'] },
env: {
CLAUDE_CODE_USE_BEDROCK: '1', ANTHROPIC_BASE_URL: native,
ANTHROPIC_API_KEY: 'native-validation-key', ANTHROPIC_AUTH_TOKEN: 'native-validation-token',
ANTHROPIC_CUSTOM_HEADERS: 'Authorization: Bearer native-header-token',
},
})
fs.writeFileSync(filename, settings)
const options = providerOptionsFor('claude', {
OPENAI_API_KEY: selectedKey, OPENAI_BASE_URL: selected, OPENAI_MODEL: 'claude-sonnet-4-6',
})
const events = []
const result = await runAgent({ kind: 'claude', executable: process.env.MELDWORK_TEST_CLAUDE_EXECUTABLE },
'Reply with OK. Do not use tools.', root, {
...options, signal: AbortSignal.timeout(25000), onEvent: event => events.push(event),
env: {
...options.env, HOME: root, USERPROFILE: root, CLAUDE_CONFIG_DIR: config,
XDG_CONFIG_HOME: root, XDG_DATA_HOME: root, XDG_STATE_HOME: root, XDG_CACHE_HOME: root,
HTTP_PROXY: '', HTTPS_PROXY: '', ALL_PROXY: '', http_proxy: '', https_proxy: '', all_proxy: '',
NO_PROXY: '*', no_proxy: '*', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
},
})
assert.equal(result.text, 'LOCAL_PROVIDER_OK')
assert.ok(requests.length > 0)
assert.ok(requests.every(request => request.label === 'selected' && request.selectedKey))
assert.equal(fs.readFileSync(filename, 'utf8'), settings)
assert.equal(JSON.stringify(events).includes(selectedKey), false)
})
@@ -5,7 +5,25 @@ const os = require('node:os')
const path = require('node:path')
const adapters = require('../../../src/agents/cli/cli-adapters.cjs')
const { managedOpenClawOptions } = require('../../../src/agents/cli/openclaw-runtime.cjs')
const { nativeOpenClawOptions } = require('../../../src/agents/cli/openclaw-runtime.cjs')
function openClawAdapterRuntime(storageRoot, workdir, sessionRef = '') {
return nativeOpenClawOptions({
storageRoot,
workdir,
...(sessionRef ? { sessionRef } : {}),
runtime: {
model: 'native/model',
provider: {
id: 'native',
baseUrl: 'https://native.example.com/v1',
api: 'openai-completions',
apiKey: 'adapter-openclaw-key',
model: { id: 'model', name: 'Native Model', input: ['text'] },
},
},
})
}
const {
executable,
readJsonWhenReady,
@@ -144,6 +162,194 @@ input.on('line', (line) => {
&& error.failure?.category === 'authentication')
})
test('Hermes ACP receives image attachments as content blocks or falls back when unsupported', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-hermes-acp-image-'))
const image = path.join(directory, 'diagram.png')
fs.writeFileSync(image, Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
))
const fakeHermes = supportsImages => executable(directory, `hermes-image-${supportsImages}.cjs`, `
const readline = require('node:readline')
if (process.argv[2] !== 'acp') {
process.stdout.write('legacy ' + process.argv.slice(2).filter(arg => arg !== '--query').join(' ').replace(${JSON.stringify(directory)}, '<dir>'))
process.exit(0)
}
const input = readline.createInterface({ input: process.stdin })
const send = value => process.stdout.write(JSON.stringify(value) + '\\n')
input.on('line', (line) => {
const message = JSON.parse(line)
const sessionId = message.params?.sessionId || 'hermes-image-session'
if (message.method === 'initialize') {
send({ jsonrpc: '2.0', id: message.id, result: {
protocolVersion: 1,
agentCapabilities: { promptCapabilities: { image: ${supportsImages} } },
} })
} else if (message.method === 'session/new') {
send({ jsonrpc: '2.0', id: message.id, result: { sessionId } })
} else if (message.method === 'session/set_mode') {
send({ jsonrpc: '2.0', id: message.id, result: {} })
} else if (message.method === 'session/prompt') {
const blocks = message.params?.prompt || []
const summary = blocks.map(block => block.type === 'image'
? 'image:' + block.mimeType + ':' + (block.data.length > 20)
: block.type).join(',')
send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: {
sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: summary },
} } })
send({ jsonrpc: '2.0', id: message.id, result: { stopReason: 'end_turn' } })
}
})
`)
t.after(async () => {
await adapters.shutdownAcpSessionRuntime?.()
fs.rmSync(directory, { recursive: true, force: true })
})
const capable = await adapters.runAgent(
{ kind: 'hermes', executable: fakeHermes(true), name: 'Hermes' },
'describe the diagram', directory,
{ acpPersistenceKey: 'workspace:hermes:image', attachments: [image] },
)
assert.equal(capable.text, 'text,image:image/png:true')
const fallback = await adapters.runAgent(
{ kind: 'hermes', executable: fakeHermes(false), name: 'Hermes' },
'describe the diagram', directory,
{ acpPersistenceKey: 'workspace:hermes:image-fallback', attachments: [image] },
)
assert.match(fallback.text, /^legacy chat --quiet --image <dir>\/diagram\.png/)
})
function resilientHermes(directory) {
return executable(directory, 'hermes-resilient-acp.cjs', `
const fs = require('node:fs')
const readline = require('node:readline')
if (process.argv[2] !== 'acp') {
process.stdout.write('legacy fallback')
process.exit(0)
}
const countFile = process.env.MELDWORK_TEST_SPAWN_COUNT_FILE
const spawnCount = (fs.existsSync(countFile) ? Number(fs.readFileSync(countFile, 'utf8')) : 0) + 1
fs.writeFileSync(countFile, String(spawnCount))
const input = readline.createInterface({ input: process.stdin })
const send = value => process.stdout.write(JSON.stringify(value) + '\\n')
let pendingSlowPrompt = null
input.on('line', (line) => {
const message = JSON.parse(line)
const sessionId = message.params?.sessionId || 'hermes-resilient-session'
if (message.method === 'initialize') {
if (process.env.MELDWORK_TEST_HANG_INIT === '1') return
send({ jsonrpc: '2.0', id: message.id, result: { protocolVersion: 1 } })
} else if (message.method === 'session/new') {
send({ jsonrpc: '2.0', id: message.id, result: { sessionId } })
} else if (message.method === 'session/set_mode') {
send({ jsonrpc: '2.0', id: message.id, result: {} })
} else if (message.method === 'session/cancel') {
if (pendingSlowPrompt) {
send({ jsonrpc: '2.0', id: pendingSlowPrompt, result: { stopReason: 'cancelled' } })
pendingSlowPrompt = null
}
} else if (message.method === 'session/prompt') {
const text = message.params?.prompt?.[0]?.text || ''
if (text === 'slow prompt') {
pendingSlowPrompt = message.id
fs.writeFileSync(process.env.MELDWORK_TEST_PROMPT_FILE, 'received')
return
}
send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: {
sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'acp answer ' + spawnCount },
} } })
send({ jsonrpc: '2.0', id: message.id, result: { stopReason: 'end_turn' } })
}
})
`)
}
test('a hanging ACP initialize times out and releases the conversation lock', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-acp-init-timeout-'))
const spawnCountFile = path.join(directory, 'spawn-count.txt')
t.after(async () => {
await adapters.shutdownAcpSessionRuntime?.()
fs.rmSync(directory, { recursive: true, force: true })
})
const agent = { kind: 'hermes', executable: resilientHermes(directory), name: 'Hermes' }
const env = { MELDWORK_TEST_SPAWN_COUNT_FILE: spawnCountFile }
const first = await within(adapters.runAgent(agent, 'first prompt', directory, {
acpPersistenceKey: 'workspace:hermes:init-timeout',
acpInitializeTimeoutMs: 300,
env: { ...env, MELDWORK_TEST_HANG_INIT: '1' },
}), 5000)
// The stuck initialize no longer blocks: the turn falls back instead of hanging.
assert.equal(first.text, 'legacy fallback')
// Only that process hangs (the flag is per call, not per spawn count: under
// load the 300 ms timeout can kill it before it records anything); the next
// turn on the same conversation key is not blocked by a lock left behind
// and gets a healthy ACP process.
const second = await within(adapters.runAgent(agent, 'second prompt', directory, {
acpPersistenceKey: 'workspace:hermes:init-timeout', env,
}), 10000)
assert.match(second.text, /^acp answer \d+$/)
})
test('a stopped ACP turn keeps the persistent process and its session when cancel is honoured', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-acp-cancel-keep-'))
const spawnCountFile = path.join(directory, 'spawn-count.txt')
const promptFile = path.join(directory, 'prompt.txt')
t.after(async () => {
await adapters.shutdownAcpSessionRuntime?.()
fs.rmSync(directory, { recursive: true, force: true })
})
const agent = { kind: 'hermes', executable: resilientHermes(directory), name: 'Hermes' }
const env = { MELDWORK_TEST_SPAWN_COUNT_FILE: spawnCountFile, MELDWORK_TEST_PROMPT_FILE: promptFile }
const sessionRefs = []
const controller = new AbortController()
const stopped = adapters.runAgent(agent, 'slow prompt', directory, {
acpPersistenceKey: 'workspace:hermes:cancel-keep',
env,
signal: controller.signal,
onSessionRef: sessionRef => sessionRefs.push(sessionRef),
}).then(() => null, error => error)
await within(readWhenReady(promptFile), 5000)
controller.abort()
const error = await within(stopped, 5000)
assert.equal(error?.message, 'LOCAL_AGENT_EXECUTION_STOPPED')
const next = await within(adapters.runAgent(agent, 'next prompt', directory, {
acpPersistenceKey: 'workspace:hermes:cancel-keep',
sessionRef: sessionRefs.at(-1),
sessionTransport: 'acp',
env,
}), 5000)
assert.equal(next.text, 'acp answer 1')
assert.equal(next.sessionRef, sessionRefs.at(-1))
assert.equal(fs.readFileSync(spawnCountFile, 'utf8'), '1')
})
test('a changed PATH between turns keeps the persistent ACP process and session', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-acp-path-churn-'))
const spawnCountFile = path.join(directory, 'spawn-count.txt')
t.after(async () => {
await adapters.shutdownAcpSessionRuntime?.()
fs.rmSync(directory, { recursive: true, force: true })
})
const agent = { kind: 'hermes', executable: resilientHermes(directory), name: 'Hermes' }
const shellPath = shell => [`/tmp/fnm_multishells/${shell}/bin`, '/usr/bin', '/bin'].join(path.delimiter)
const first = await adapters.runAgent(agent, 'first prompt', directory, {
acpPersistenceKey: 'workspace:hermes:path-churn',
env: { MELDWORK_TEST_SPAWN_COUNT_FILE: spawnCountFile, PATH: shellPath('1001_1') },
})
const second = await adapters.runAgent(agent, 'second prompt', directory, {
acpPersistenceKey: 'workspace:hermes:path-churn',
sessionRef: first.sessionRef,
sessionTransport: 'acp',
env: { MELDWORK_TEST_SPAWN_COUNT_FILE: spawnCountFile, PATH: shellPath('1002_2') },
})
assert.equal(second.sessionRef, first.sessionRef)
assert.equal(second.text, 'acp answer 1')
assert.equal(fs.readFileSync(spawnCountFile, 'utf8'), '1')
})
test('ACP closes a start-only tool with the partial terminal outcome', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-acp-start-only-tool-'))
const cli = executable(directory, 'acp-start-only-tool.cjs', `
@@ -628,16 +834,7 @@ input.on('line', (line) => {
const spec = adapters.invocation('openclaw', cli, workdir, stableSessionRef)
assert.equal(spec.eventTransport, 'acp')
const runtime = managedOpenClawOptions({
storageRoot: directory,
workdir,
sessionRef: stableSessionRef,
provider: {
OPENAI_API_KEY: 'adapter-openclaw-key',
OPENAI_BASE_URL: 'https://api.example.com/v1',
OPENAI_MODEL: 'adapter-model',
},
})
const runtime = openClawAdapterRuntime(directory, workdir, stableSessionRef)
const events = []
let receivedStream
const streamReceived = new Promise(resolve => { receivedStream = resolve })
@@ -764,15 +961,7 @@ input.on('line', (line) => {
})
`)
let settled = false
const runtime = managedOpenClawOptions({
storageRoot: directory,
workdir,
provider: {
OPENAI_API_KEY: 'adapter-openclaw-key',
OPENAI_BASE_URL: 'https://api.example.com/v1',
OPENAI_MODEL: 'adapter-model',
},
})
const runtime = openClawAdapterRuntime(directory, workdir)
const resultPromise = adapters.runAgent(
{ kind: 'openclaw', executable: cli, name: 'OpenClaw' },
'hold until shutdown',
@@ -857,15 +1046,7 @@ input.on('line', (line) => {
})
`)
let settled = false
const runtime = managedOpenClawOptions({
storageRoot: directory,
workdir,
provider: {
OPENAI_API_KEY: 'adapter-openclaw-key',
OPENAI_BASE_URL: 'https://api.example.com/v1',
OPENAI_MODEL: 'adapter-model',
},
})
const runtime = openClawAdapterRuntime(directory, workdir)
const resultPromise = adapters.runAgent(
{ kind: 'openclaw', executable: cli, name: 'OpenClaw' },
'stop during Gateway readiness',
@@ -957,15 +1138,7 @@ input.on('line', (line) => {
})
`)
let settled = false
const runtime = managedOpenClawOptions({
storageRoot: directory,
workdir,
provider: {
OPENAI_API_KEY: 'adapter-openclaw-key',
OPENAI_BASE_URL: 'https://api.example.com/v1',
OPENAI_MODEL: 'adapter-model',
},
})
const runtime = openClawAdapterRuntime(directory, workdir)
const resultPromise = adapters.runAgent(
{ kind: 'openclaw', executable: cli, name: 'OpenClaw' },
'stop during Gateway health',
@@ -999,3 +1172,57 @@ input.on('line', (line) => {
error => error?.message === 'LOCAL_AGENT_EXECUTION_STOPPED',
)
})
test('switching between read-only and write keeps one ACP process when only the session mode differs', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-acp-mode-switch-'))
const spawnCountFile = path.join(directory, 'spawn-count.txt')
const modesFile = path.join(directory, 'modes.jsonl')
t.after(async () => {
await adapters.shutdownAcpSessionRuntime?.()
fs.rmSync(directory, { recursive: true, force: true })
})
const cli = executable(directory, 'mimo-mode-switch.cjs', `
const fs = require('node:fs')
const readline = require('node:readline')
if (process.argv[2] !== 'acp') process.exit(2)
const countFile = process.env.MELDWORK_TEST_SPAWN_COUNT_FILE
fs.writeFileSync(countFile, String((fs.existsSync(countFile) ? Number(fs.readFileSync(countFile, 'utf8')) : 0) + 1))
const input = readline.createInterface({ input: process.stdin })
const send = value => process.stdout.write(JSON.stringify(value) + '\\n')
input.on('line', (line) => {
const message = JSON.parse(line)
const sessionId = message.params?.sessionId || 'mimo-mode-session'
if (message.method === 'initialize') send({ jsonrpc: '2.0', id: message.id, result: { protocolVersion: 1 } })
else if (message.method === 'session/new') send({ jsonrpc: '2.0', id: message.id, result: { sessionId } })
else if (message.method === 'session/set_mode') {
fs.appendFileSync(process.env.MELDWORK_TEST_MODES_FILE, message.params.modeId + '\\n')
send({ jsonrpc: '2.0', id: message.id, result: {} })
} else if (message.method === 'session/prompt') {
send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: {
sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'mimo reply' },
} } })
send({ jsonrpc: '2.0', id: message.id, result: { stopReason: 'end_turn' } })
}
})
`)
const agent = { kind: 'mimo', executable: cli, name: 'MiMo' }
const env = { MELDWORK_TEST_SPAWN_COUNT_FILE: spawnCountFile, MELDWORK_TEST_MODES_FILE: modesFile }
const sessionRefs = []
const turn = (sandbox, sessionRef) => within(adapters.runAgent(agent, `turn in ${sandbox}`, directory, {
acpPersistenceKey: 'workspace:mimo:mode-switch',
sandbox,
sessionRef,
sessionTransport: sessionRef ? 'acp' : '',
env,
onSessionRef: ref => sessionRefs.push(ref),
}), 10000)
const proposal = await turn('read-only', '')
const discussion = await turn('workspace-write', sessionRefs.at(-1))
const review = await turn('read-only', sessionRefs.at(-1))
assert.equal([proposal, discussion, review].every(result => result.text === 'mimo reply'), true)
assert.equal(fs.readFileSync(spawnCountFile, 'utf8'), '1', 'one process served all three turns')
assert.deepEqual(fs.readFileSync(modesFile, 'utf8').trim().split('\n'), ['plan', 'build', 'plan'])
assert.equal(new Set(sessionRefs).size, 1, 'the native session was kept')
})
@@ -5,7 +5,26 @@ const os = require('node:os')
const path = require('node:path')
const { childEnvironment, failedAgentProcessError } = require('../../../src/agents/cli/cli-process-support.cjs')
const { managedOpenClawOptions } = require('../../../src/agents/cli/openclaw-runtime.cjs')
const { nativeOpenClawOptions } = require('../../../src/agents/cli/openclaw-runtime.cjs')
const { workBuddyInstall } = require('../../support/cli-adapters-test-helpers.cjs')
function openClawChildEnvRuntime(directory, workdir) {
return nativeOpenClawOptions({
storageRoot: directory,
workdir,
sessionRef: 'agent:main:child-env',
runtime: {
model: 'native/model',
provider: {
id: 'native',
baseUrl: 'https://native.example.com/v1',
api: 'openai-completions',
apiKey: 'selected-openclaw-key',
model: { id: 'model', name: 'Native Model', input: ['text'] },
},
},
})
}
test('local Agent child environments keep supported runtime settings but reject process injection', () => {
const env = childEnvironment(
@@ -26,6 +45,18 @@ test('local Agent child environments keep supported runtime settings but reject
assert.equal(Object.hasOwn(env, 'DYLD_INSERT_LIBRARIES'), false)
})
test('WorkBuddy runs load the lite bundle only when that is all the install ships', (t) => {
const liteOnly = workBuddyInstall(t, ['codebuddy-lite-wb.mjs'])
const withHeadless = workBuddyInstall(t, ['codebuddy-lite-wb.mjs', 'codebuddy-headless.js'])
const environmentFor = (kind, executable) => childEnvironment(
{ kind, executable }, '/tmp/workspace', { env: {} }, process.platform,
)
assert.equal(environmentFor('workbuddy', liteOnly).CODEBUDDY_FORCE_LITE_WB_BUNDLE, '1')
assert.equal(Object.hasOwn(environmentFor('workbuddy', withHeadless), 'CODEBUDDY_FORCE_LITE_WB_BUNDLE'), false)
assert.equal(Object.hasOwn(environmentFor('claude', liteOnly), 'CODEBUDDY_FORCE_LITE_WB_BUNDLE'), false)
})
test('process diagnostics require explicit authentication failure evidence', () => {
for (const diagnostic of [
'EACCES: permission denied opening auth-service.ts',
@@ -69,16 +100,7 @@ test('OpenClaw child environment keeps only its guarded runtime and selected cre
}
})
const runtime = managedOpenClawOptions({
storageRoot: directory,
workdir,
sessionRef: 'agent:main:child-env',
provider: {
OPENAI_API_KEY: 'selected-openclaw-key',
OPENAI_BASE_URL: 'https://api.example.com/v1',
OPENAI_MODEL: 'example-model',
},
})
const runtime = openClawChildEnvRuntime(directory, workdir)
const env = childEnvironment(
{ kind: 'openclaw' },
workdir,
@@ -89,7 +111,7 @@ test('OpenClaw child environment keeps only its guarded runtime and selected cre
https_proxy: 'http://proxy.example:3128',
NO_PROXY: 'localhost,127.0.0.1,::1',
NODE_EXTRA_CA_CERTS: '/certs/company.pem',
MELDWORK_OPENCLAW_NATIVE_API_KEY: 'wrong-openclaw-key',
MELDWORK_OPENCLAW_API_KEY: 'legacy-openclaw-key',
GITHUB_TOKEN: 'caller-secret',
ANTHROPIC_API_KEY: 'caller-anthropic-secret',
XDG_CONFIG_HOME: '/tmp/caller-config',
@@ -98,13 +120,13 @@ test('OpenClaw child environment keeps only its guarded runtime and selected cre
process.platform,
)
assert.equal(env.MELDWORK_OPENCLAW_API_KEY, 'selected-openclaw-key')
assert.equal(env.MELDWORK_OPENCLAW_NATIVE_API_KEY, 'selected-openclaw-key')
assert.equal(env.https_proxy, 'http://proxy.example:3128')
assert.equal(env.NO_PROXY, 'localhost,127.0.0.1,::1')
assert.equal(env.NODE_EXTRA_CA_CERTS, '/certs/company.pem')
assert.equal(env.OPENCLAW_GATEWAY_TOKEN, runtime.env.OPENCLAW_GATEWAY_TOKEN)
assert.equal(env.OPENCLAW_DISABLE_BONJOUR, '1')
assert.equal(Object.hasOwn(env, 'MELDWORK_OPENCLAW_NATIVE_API_KEY'), false)
assert.equal(Object.hasOwn(env, 'MELDWORK_OPENCLAW_API_KEY'), false)
assert.equal(Object.hasOwn(env, 'GITHUB_TOKEN'), false)
assert.equal(Object.hasOwn(env, 'ANTHROPIC_API_KEY'), false)
assert.equal(env.OPENCLAW_WORKSPACE_DIR, workdir)
+64 -116
View File
@@ -7,7 +7,6 @@ const path = require('node:path')
const {
configureOpenClawGatewayRuntime,
managedOpenClawOptions,
nativeOpenClawOptions,
refreshOpenClawRuntimeAfterStartup,
validateOpenClawRuntimeGuard,
@@ -26,14 +25,6 @@ function fixture() {
return { directory, workdir }
}
function provider(apiKey = 'test-openclaw-key') {
return {
OPENAI_API_KEY: apiKey,
OPENAI_BASE_URL: 'https://api.example.com/v1',
OPENAI_MODEL: 'example-model',
}
}
function nativeRuntime(apiKey = 'native-openclaw-key') {
return {
model: 'native/model',
@@ -60,44 +51,35 @@ function runtimeScope(sessionRef, workdir) {
.slice(0, 24)
}
test('managed runtime isolates OpenClaw state and keeps the API key out of config', (t) => {
test('native runtime scope is keyed by session, workspace, and CLI version', (t) => {
const { directory, workdir } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const result = managedOpenClawOptions({
const base = {
storageRoot: directory,
workdir,
sessionRef: 'agent:main:desktop-meldwork-group-topic-openclaw',
provider: provider(),
})
const configText = fs.readFileSync(result.env.OPENCLAW_CONFIG_PATH, 'utf8')
const config = JSON.parse(configText)
sessionRef: 'agent:main:desktop-meldwork-scope-openclaw',
runtime: nativeRuntime(),
}
const versionA = nativeOpenClawOptions({ ...base, openclawVersion: 'OpenClaw 2026.7.1-2 (a1b2c3)' })
const versionARepeat = nativeOpenClawOptions({ ...base, openclawVersion: 'OpenClaw 2026.7.1-2 (a1b2c3)' })
const versionB = nativeOpenClawOptions({ ...base, openclawVersion: 'OpenClaw 2026.9.1 (ad6fe23)' })
const unversioned = nativeOpenClawOptions({ ...base })
assert.equal(configText.includes('test-openclaw-key'), false)
assert.deepEqual(config.models.providers['meldwork-desktop'].apiKey, {
source: 'env', provider: 'default', id: 'MELDWORK_OPENCLAW_API_KEY',
})
assert.equal(config.agents.defaults.workspace, workdir)
assert.equal(config.agents.defaults.skipBootstrap, true)
assert.deepEqual(config.tools.allow, [
'read', 'web_search', 'web_fetch', 'memory_search', 'memory_get', 'session_status',
])
assert.equal(config.tools.fs.workspaceOnly, true)
assert.equal(config.tools.elevated.enabled, false)
assert.equal(result.env.MELDWORK_OPENCLAW_API_KEY, 'test-openclaw-key')
assert.equal(result.env.OPENCLAW_WORKSPACE_DIR, workdir)
assert.ok(result.env.OPENCLAW_STATE_DIR.startsWith(fs.realpathSync(directory)))
assert.equal(fs.statSync(result.env.OPENCLAW_CONFIG_PATH).mode & 0o777, 0o600)
assert.equal(versionA.env.OPENCLAW_STATE_DIR, versionARepeat.env.OPENCLAW_STATE_DIR)
assert.notEqual(versionA.env.OPENCLAW_STATE_DIR, versionB.env.OPENCLAW_STATE_DIR)
assert.notEqual(versionA.env.OPENCLAW_STATE_DIR, unversioned.env.OPENCLAW_STATE_DIR)
assert.equal(versionB.env.OPENCLAW_STATE_DIR.startsWith(fs.realpathSync(directory)), true)
})
test('Gateway setup rewrites the guarded config without persisting credentials', (t) => {
const { directory, workdir } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const runtime = managedOpenClawOptions({
const runtime = nativeOpenClawOptions({
storageRoot: directory,
workdir,
sessionRef: 'agent:main:gateway-runtime',
provider: provider(),
runtime: nativeRuntime(),
})
const configured = configureOpenClawGatewayRuntime(runtime, 43123)
@@ -126,15 +108,15 @@ test('Gateway setup rewrites the guarded config without persisting credentials',
assert.equal(config.update.checkOnStart, false)
assert.equal(config.update.auto.enabled, false)
assert.equal(config.logging.file, path.join(runtime.env.OPENCLAW_STATE_DIR, 'gateway.log'))
assert.equal(configText.includes(runtime.env.MELDWORK_OPENCLAW_API_KEY), false)
assert.equal(configText.includes(runtime.env.MELDWORK_OPENCLAW_NATIVE_API_KEY), false)
assert.equal(configText.includes(runtime.env.OPENCLAW_GATEWAY_TOKEN), false)
})
test('startup migration refreshes the guard without changing execution policy', (t) => {
const { directory, workdir } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const options = configureOpenClawGatewayRuntime(managedOpenClawOptions({
storageRoot: directory, workdir, provider: provider(),
const options = configureOpenClawGatewayRuntime(nativeOpenClawOptions({
storageRoot: directory, workdir, runtime: nativeRuntime(),
}), 43123)
const configPath = options.env.OPENCLAW_CONFIG_PATH
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'))
@@ -157,7 +139,7 @@ test('startup migration rejects changed policies, credentials, paths and unknown
workspace: config => { config.agents.defaults.workspace = os.homedir() },
agentOverride: config => { config.agents.entries = { main: { tools: { allow: ['exec'] } } } },
credentials: config => { config.gateway.auth.token = 'replacement-token' },
provider: config => { config.models.providers['meldwork-desktop'].baseUrl = 'https://other.example' },
provider: config => { config.models.providers.native.baseUrl = 'https://other.example' },
bind: config => { config.gateway.bind = 'lan' },
discovery: config => { config.discovery.wideArea.enabled = true },
unknown: config => { config.plugins = { enabled: true } },
@@ -166,8 +148,8 @@ test('startup migration rejects changed policies, credentials, paths and unknown
await t.test(name, () => {
const { directory, workdir } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const options = configureOpenClawGatewayRuntime(managedOpenClawOptions({
storageRoot: directory, workdir, provider: provider(),
const options = configureOpenClawGatewayRuntime(nativeOpenClawOptions({
storageRoot: directory, workdir, runtime: nativeRuntime(),
}), 43123)
const configPath = options.env.OPENCLAW_CONFIG_PATH
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'))
@@ -183,7 +165,7 @@ test('startup migration rejects changed policies, credentials, paths and unknown
test('startup migration rejects symlinks and broadened file permissions', (t) => {
const { directory, workdir } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const options = managedOpenClawOptions({ storageRoot: directory, workdir, provider: provider() })
const options = nativeOpenClawOptions({ storageRoot: directory, workdir, runtime: nativeRuntime() })
const configPath = options.env.OPENCLAW_CONFIG_PATH
fs.renameSync(configPath, `${configPath}.original`)
fs.symlinkSync(`${configPath}.original`, configPath)
@@ -199,7 +181,7 @@ test('startup migration rejects symlinks and broadened file permissions', (t) =>
test('startup migration rejects an oversized config before parsing it', (t) => {
const { directory, workdir } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const options = managedOpenClawOptions({ storageRoot: directory, workdir, provider: provider() })
const options = nativeOpenClawOptions({ storageRoot: directory, workdir, runtime: nativeRuntime() })
fs.appendFileSync(options.env.OPENCLAW_CONFIG_PATH, ' '.repeat(256 * 1024))
assert.throws(() => refreshOpenClawRuntimeAfterStartup(options), {
message: 'OPENCLAW_RUNTIME_UNSAFE_PATH',
@@ -265,11 +247,11 @@ process.on('SIGTERM', () => {
setInterval(() => {}, 1000)
`)
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const runtime = managedOpenClawOptions({
const runtime = nativeOpenClawOptions({
storageRoot: directory,
workdir,
sessionRef: 'agent:main:gateway-lifecycle',
provider: provider(),
runtime: nativeRuntime(),
})
let callbackPort
@@ -343,11 +325,11 @@ process.on('SIGTERM', () => {
setInterval(() => {}, 1000)
`)
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const runtime = managedOpenClawOptions({
const runtime = nativeOpenClawOptions({
storageRoot: directory,
workdir,
sessionRef: 'agent:main:gateway-callback-failed',
provider: provider(),
runtime: nativeRuntime(),
})
await assert.rejects(() => withOpenClawGateway({
@@ -401,11 +383,11 @@ if (args[commandOffset + 1] === 'health') {
}
`)
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const runtime = managedOpenClawOptions({
const runtime = nativeOpenClawOptions({
storageRoot: directory,
workdir,
sessionRef: `agent:main:gateway-health-${scenario.name}`,
provider: provider(),
runtime: nativeRuntime(),
})
let callbackCalled = false
@@ -436,23 +418,6 @@ test('OpenClaw ACP keeps deterministic global CLI flags before the subcommand',
])
})
test('managed runtime defensively normalizes chat completion endpoints to the API root', (t) => {
const { directory, workdir } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const result = managedOpenClawOptions({
storageRoot: directory,
workdir,
provider: {
...provider(),
OPENAI_BASE_URL: 'https://api.example.com/v1/chat/completions/',
},
})
const config = JSON.parse(fs.readFileSync(result.env.OPENCLAW_CONFIG_PATH, 'utf8'))
assert.equal(config.models.providers['meldwork-desktop'].baseUrl, 'https://api.example.com/v1')
})
test('write authorization uses an immutable config and preserves the topic state', (t) => {
const { directory, workdir } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
@@ -461,10 +426,10 @@ test('write authorization uses an immutable config and preserves the topic state
storageRoot: directory,
workdir,
sessionRef: 'agent:main:desktop-meldwork-group-topic-openclaw',
provider: provider(),
runtime: nativeRuntime(),
}
const readOnly = managedOpenClawOptions(input)
const writable = managedOpenClawOptions({ ...input, allowWrite: true })
const readOnly = nativeOpenClawOptions(input)
const writable = nativeOpenClawOptions({ ...input, allowWrite: true })
const readOnlyConfig = JSON.parse(fs.readFileSync(readOnly.env.OPENCLAW_CONFIG_PATH, 'utf8'))
const writableConfig = JSON.parse(fs.readFileSync(writable.env.OPENCLAW_CONFIG_PATH, 'utf8'))
@@ -477,17 +442,13 @@ test('write authorization uses an immutable config and preserves the topic state
assert.equal(writableConfig.tools.exec.security, 'deny')
})
test('native auth uses the same isolated tool policy without importing user configuration', (t) => {
test('native auth uses the isolated tool policy without importing user configuration', (t) => {
const { directory, workdir } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const common = {
const nativeOptions = nativeOpenClawOptions({
storageRoot: directory,
workdir,
sessionRef: 'agent:main:desktop-meldwork-native-openclaw',
}
const providerOptions = managedOpenClawOptions({ ...common, provider: provider() })
const nativeOptions = nativeOpenClawOptions({
...common,
runtime: {
...nativeRuntime(),
provider: {
@@ -498,10 +459,11 @@ test('native auth uses the same isolated tool policy without importing user conf
tools: { allow: ['exec'] },
},
})
const providerConfig = JSON.parse(fs.readFileSync(providerOptions.env.OPENCLAW_CONFIG_PATH, 'utf8'))
const nativeConfig = JSON.parse(fs.readFileSync(nativeOptions.env.OPENCLAW_CONFIG_PATH, 'utf8'))
assert.deepEqual(nativeConfig.tools, providerConfig.tools)
assert.deepEqual(nativeConfig.tools.allow, [
'read', 'web_search', 'web_fetch', 'memory_search', 'memory_get', 'session_status',
])
assert.equal(nativeConfig.models.mode, 'replace')
assert.equal(nativeConfig.agents.defaults.model.primary, 'native/model')
assert.equal(Object.hasOwn(nativeConfig.agents, 'list'), false)
@@ -515,7 +477,6 @@ test('native auth uses the same isolated tool policy without importing user conf
assert.equal(JSON.stringify(nativeConfig).includes('native-openclaw-key'), false)
assert.equal(Object.hasOwn(nativeConfig.models.providers.native.models[0], 'params'), false)
assert.equal(nativeOptions.env.MELDWORK_OPENCLAW_NATIVE_API_KEY, 'native-openclaw-key')
assert.equal(Object.hasOwn(nativeOptions.env, 'MELDWORK_OPENCLAW_API_KEY'), false)
assert.ok(nativeOptions.env.OPENCLAW_STATE_DIR.startsWith(fs.realpathSync(directory)))
})
@@ -571,7 +532,7 @@ test('native runtime descriptors fail closed before a config is written', (t) =>
}
})
test('managed and native runtime boundaries reject every SecretRef form', (t) => {
test('native runtime boundaries reject every SecretRef form', (t) => {
const { directory, workdir } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const references = [
@@ -592,11 +553,6 @@ test('managed and native runtime boundaries reject every SecretRef form', (t) =>
]
for (const apiKey of references) {
assert.throws(() => managedOpenClawOptions({
storageRoot: directory,
workdir,
provider: provider(apiKey),
}), { message: 'OPENCLAW_PROVIDER_INVALID' })
assert.throws(() => nativeOpenClawOptions({
storageRoot: directory,
workdir,
@@ -634,11 +590,11 @@ test('runtime paths reject pre-existing symlinks without writing outside app sto
}[position]
fs.symlinkSync(external, target, 'dir')
assert.throws(() => managedOpenClawOptions({
assert.throws(() => nativeOpenClawOptions({
storageRoot,
workdir,
sessionRef,
provider: provider(),
runtime: nativeRuntime(),
}), { message: 'OPENCLAW_RUNTIME_UNSAFE_PATH' })
assert.deepEqual(fs.readdirSync(external), [])
}
@@ -660,7 +616,7 @@ test('runtime paths repair overly broad directory permissions', {
fs.chmodSync(target, 0o777)
}
managedOpenClawOptions({ storageRoot, workdir, sessionRef, provider: provider() })
nativeOpenClawOptions({ storageRoot, workdir, sessionRef, runtime: nativeRuntime() })
for (const target of directories) {
assert.equal(fs.statSync(target).mode & 0o777, 0o700)
@@ -717,11 +673,11 @@ test('runtime config creation revalidates parent identities before writing', {
}
try {
assert.throws(() => managedOpenClawOptions({
assert.throws(() => nativeOpenClawOptions({
storageRoot: directory,
workdir,
sessionRef: 'agent:main:runtime-parent-change',
provider: provider(),
runtime: nativeRuntime(),
}), { message: 'OPENCLAW_RUNTIME_UNSAFE_PATH' })
} finally {
fs.realpathSync = originalRealpath
@@ -735,11 +691,11 @@ test('runtime guard rejects directory and config identity changes before executi
const { directory, workdir } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const directoryOptions = managedOpenClawOptions({
const directoryOptions = nativeOpenClawOptions({
storageRoot: directory,
workdir,
sessionRef: 'agent:main:runtime-directory-guard',
provider: provider(),
runtime: nativeRuntime(),
})
const runtimeRoot = path.dirname(directoryOptions.env.OPENCLAW_CONFIG_PATH)
const originalRuntimeRoot = `${runtimeRoot}.original`
@@ -753,11 +709,11 @@ test('runtime guard rejects directory and config identity changes before executi
directoryOptions.env,
), { message: 'OPENCLAW_RUNTIME_UNSAFE_PATH' })
const fileOptions = managedOpenClawOptions({
const fileOptions = nativeOpenClawOptions({
storageRoot: directory,
workdir,
sessionRef: 'agent:main:runtime-file-guard',
provider: provider(),
runtime: nativeRuntime(),
})
const configPath = fileOptions.env.OPENCLAW_CONFIG_PATH
const contents = fs.readFileSync(configPath)
@@ -774,20 +730,20 @@ test('runtime guard rejects in-place config and credential changes without expos
const { directory, workdir } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const configOptions = managedOpenClawOptions({
const configOptions = nativeOpenClawOptions({
storageRoot: directory,
workdir,
sessionRef: 'agent:main:runtime-content-guard',
provider: provider(),
runtime: nativeRuntime(),
})
const serializedGuard = JSON.stringify(configOptions.openClawRuntimeGuard)
const credentialDigest = crypto.createHash('sha256')
.update(configOptions.env.MELDWORK_OPENCLAW_API_KEY)
.update(configOptions.env.MELDWORK_OPENCLAW_NATIVE_API_KEY)
.digest('hex')
const gatewayDigest = crypto.createHash('sha256')
.update(configOptions.env.OPENCLAW_GATEWAY_TOKEN)
.digest('hex')
assert.equal(serializedGuard.includes(configOptions.env.MELDWORK_OPENCLAW_API_KEY), false)
assert.equal(serializedGuard.includes(configOptions.env.MELDWORK_OPENCLAW_NATIVE_API_KEY), false)
assert.equal(serializedGuard.includes(configOptions.env.OPENCLAW_GATEWAY_TOKEN), false)
assert.equal(serializedGuard.includes(credentialDigest), false)
assert.equal(serializedGuard.includes(gatewayDigest), false)
@@ -802,17 +758,17 @@ test('runtime guard rejects in-place config and credential changes without expos
configOptions.env,
), { message: 'OPENCLAW_RUNTIME_UNSAFE_PATH' })
const credentialOptions = managedOpenClawOptions({
const credentialOptions = nativeOpenClawOptions({
storageRoot: directory,
workdir,
sessionRef: 'agent:main:runtime-credential-guard',
provider: provider(),
runtime: nativeRuntime(),
})
assert.throws(() => validateOpenClawRuntimeGuard(
credentialOptions.openClawRuntimeGuard,
{
...credentialOptions.env,
MELDWORK_OPENCLAW_API_KEY: 'tampered-openclaw-key',
MELDWORK_OPENCLAW_NATIVE_API_KEY: 'tampered-openclaw-key',
},
), { message: 'OPENCLAW_RUNTIME_CREDENTIAL_SCOPE_INVALID' })
assert.throws(() => validateOpenClawRuntimeGuard(
@@ -831,11 +787,11 @@ test('runtime guard rejects permissions broadened after creation', {
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
for (const target of ['directory', 'config']) {
const options = managedOpenClawOptions({
const options = nativeOpenClawOptions({
storageRoot: directory,
workdir,
sessionRef: `agent:main:runtime-${target}-permission-guard`,
provider: provider(),
runtime: nativeRuntime(),
})
fs.chmodSync(
target === 'directory' ? options.env.OPENCLAW_HOME : options.env.OPENCLAW_CONFIG_PATH,
@@ -857,13 +813,13 @@ test('runtime paths are stable and isolated per topic and workspace', (t) => {
storageRoot: directory,
workdir,
sessionRef: 'agent:main:desktop-meldwork-group-topic-openclaw',
provider: provider(),
runtime: nativeRuntime(),
}
const first = managedOpenClawOptions(input)
const repeated = managedOpenClawOptions(input)
const otherTopic = managedOpenClawOptions({ ...input, sessionRef: `${input.sessionRef}-other` })
const otherWorkspace = managedOpenClawOptions({ ...input, workdir: otherWorkdir })
const first = nativeOpenClawOptions(input)
const repeated = nativeOpenClawOptions(input)
const otherTopic = nativeOpenClawOptions({ ...input, sessionRef: `${input.sessionRef}-other` })
const otherWorkspace = nativeOpenClawOptions({ ...input, workdir: otherWorkdir })
assert.equal(repeated.env.OPENCLAW_STATE_DIR, first.env.OPENCLAW_STATE_DIR)
assert.notEqual(otherTopic.env.OPENCLAW_STATE_DIR, first.env.OPENCLAW_STATE_DIR)
@@ -871,19 +827,11 @@ test('runtime paths are stable and isolated per topic and workspace', (t) => {
assert.equal(otherWorkspace.env.OPENCLAW_WORKSPACE_DIR, otherWorkdir)
})
test('invalid runtime scopes and Provider metadata fail closed', (t) => {
test('invalid runtime scopes fail closed', (t) => {
const { directory, workdir } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
assert.throws(() => managedOpenClawOptions({
storageRoot: 'relative', workdir, provider: provider(),
assert.throws(() => nativeOpenClawOptions({
storageRoot: 'relative', workdir, runtime: nativeRuntime(),
}), { message: 'OPENCLAW_RUNTIME_INVALID_SCOPE' })
assert.throws(() => managedOpenClawOptions({
storageRoot: directory, workdir,
provider: provider(''),
}), { message: 'OPENCLAW_PROVIDER_INVALID' })
assert.throws(() => managedOpenClawOptions({
storageRoot: directory, workdir,
provider: { ...provider(), OPENAI_BASE_URL: 'http://provider.example/v1' },
}), { message: 'OPENCLAW_PROVIDER_INVALID' })
})
@@ -102,7 +102,6 @@ test('discovers an explicitly installed approved sample and exposes generic sani
const [catalog] = connectors.catalog()
assert.equal(catalog.kind, agent.kind)
assert.equal(catalog.connector, true)
assert.equal(catalog.providerMode, 'connector')
assert.equal(catalog.description, SAMPLE_AGENT_CONNECTOR_MANIFEST.description)
assert.doesNotMatch(JSON.stringify(catalog), /private\/bin|credential-ref/i)
Loaded 100 of 216 files, more files were not shown because too many files have changed in this diff. Show more