Compare commits

..
Author SHA1 Message Date
Ryder Sun 395be37a52 fix: align multimodal attachments with native agent skills
Replace custom media generation routing with native Agent skill execution and update FileCard-style attachment icons, inline audio playback, and image/video previews. Verified with frontend tests and desktop checks.
2026-09-04 21:35:54 +08:00
Ryder Sun 13d649471c docs: expand supported cli table to six columns 2026-09-01 14:06:25 +08:00
Ryder Sun 1b81df6d1b Opt Readme 2026-09-01 13:52:00 +08:00
Ryder Sun 2e939c4cf9 docs: restore README banner and CLI layout 2026-09-01 12:51:42 +08:00
Ryder Sun b6a7e35dd3 docs: clarify README agent landscape and demos 2026-09-01 11:52:03 +08:00
Ryder Sun 523970ff49 docs: sharpen README category keywords 2026-09-01 11:38:17 +08:00
Ryder Sun 41014fd072 docs: align bilingual README terminology 2026-09-01 11:34:41 +08:00
Ryder Sun 29646b2845 docs: refine bilingual README for discoverability 2026-09-01 11:27:08 +08:00
Ryder Sun 152edd7bb2 docs: improve AI discoverability and project positioning 2026-09-01 10:19:05 +08:00
358 changed files with 15009 additions and 29452 deletions

No files matched your search

-3
View File
@@ -31,9 +31,6 @@ jobs:
- name: Run unit tests
run: npm test
- name: Check website and release licensing consistency
run: node --test ../meldwork-landing/test/landing.test.cjs
- name: Build web application
run: npm run build
+1 -2
View File
@@ -11,8 +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.
- Respect native Agent configuration and keep CLI integration provider-agnostic. Do not add gateway-specific adapters, provider overrides, or bundled patches for external tools such as Magpie.
- Never expose executable paths, tokens, credentials, or unrestricted shell execution to renderer code.
- Never expose executable paths, tokens, provider 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.
+1 -98
View File
@@ -1,102 +1,5 @@
# Changelog
## 1.0.7 - 2026-10-27 (Meldwork-V1.0.7 prerelease)
- Preserve streamed discussion replies across cancellation and reload; scope ACP notifications to the active session.
- Support Hermes native stream JSON and avoid MiMo interactive plan prompts in headless runs.
- Add Zcode with its official logo before Pi Agent, native default configuration, and explicit missing-key errors.
- Let WorkBuddy use an explicitly selected model from its desktop configuration, without exposing credentials to the renderer.
- Keep Chinese slash-separated text intact while retaining credential and local-path redaction.
- Unify application, artifact, README, and website versions at 1.0.7.
- License Meldwork's original source, documentation, and website under Apache-2.0: commercial use is permitted without separate authorization or license fees. Third-party notices remain applicable. Older entries below describe their historical release terms.
- Native acceptance includes a real WorkBuddy response; six-Agent unlimited discussion cancellation and reload were checked with simulated adapters. Zcode's default provider still needs a valid API key; the observed OpenCode router's invalid `tool_calls` finish reason remains an upstream issue.
- Apple silicon DMG and ZIP remain ad-hoc signed and unnotarized.
## 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
- Accept validated OpenClaw gateway configuration migrations without disabling path, permission, or credential isolation checks.
- Keep installed Agents visible when credential or capability checks fail; isolate failed probes and refresh the catalog after current readiness settles.
- Refresh native shell settings before manual detection and preserve supported proxy, CA, cloud Provider, and model-region settings without silently replacing a selected Provider.
### Group Conversations
- Route peer requests through explicit Agent decisions instead of interpreting prose mentions or media keywords as commands.
- Persist AI task completion separately from individual invocation success and human adoption; support single-Agent automatic work and exact visible answers.
- Resume human clarification gates after restart, reject conflicting responses, and prevent repeated responses from replaying calls.
- Serialize workspace writers, recover interrupted writes through bound approvals, retain partial results, and send acknowledged session context incrementally.
- Preserve completed and stopped task states after restart and show healthy contributions alongside failed participants.
### Verification And Distribution
- Frontend 343/343, desktop 1562/1562, and deterministic evaluation 6 cases / 18 results passed.
- Web and desktop builds, packaging, real packaged Codex/OpenClaw group execution, cancellation, and restart acceptance passed on Apple silicon macOS.
- 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; cloud credential forwarding is not cloud-account certification. Full evidence: `docs/releases/Meldwork-V1.0.5.md`.
## 0.1.4 - 2026-09-01 (Meldwork-V1.0.4 prerelease)
### Natural Group Discussions
- Sequential discussion mode now runs one Agent at a time in the configured CLI order and records every round visibly.
- Auto Discussion starts with concurrent proposals, then lets Agent responses route the next turn through exact final-line `@Agent` mentions. One mention runs one Agent; multiple mentions run concurrently.
- Natural discussion prompts allow Agents to exchange views before work and review instead of forcing every turn into a review-only role.
### Session And Recovery
- Reuses one native Agent session per Agent task across discussion rounds and restarts.
- Persists round messages and checkpoints before and after Agent execution so completed turns are recovered without duplicate reruns.
- Rejects stale operation and snapshot bindings before late results can be committed.
### Verification And Distribution
- Focused natural discussion coverage: 17/17 tests passed.
- Desktop syntax checks and `git diff --check` passed.
- The Apple silicon DMG and ZIP are ad-hoc signed and not notarized. Live CLI behavior still depends on each installed Agent's version, authentication, Provider, and capabilities.
## 0.1.3 - 2026-08-18 (Meldwork-V1.0.3 prerelease)
### Concurrent Conversations
@@ -163,7 +66,7 @@
- Sets the desktop package version to `0.1.2` with permanent Bundle ID `com.rydersun.meldwork`.
- Produces Apple silicon DMG and ZIP prerelease artifacts using ad-hoc signing. This is not Apple Developer ID signing or notarization and may require Open Anyway on first launch.
- Keeps `dist:public` as the fail-closed future formal-distribution path requiring a Developer ID identity, Hardened Runtime, complete Apple notarization credentials, and post-sign validation.
- This historical release used the Meldwork Non-Commercial Source License and required prior written permission for commercial use. Current releases use Apache-2.0 as stated above.
- Uses the Meldwork Non-Commercial Source License; commercial use requires prior written permission.
### Verification
-51
View File
@@ -1,51 +0,0 @@
# Code of Conduct
Meldwork is a workspace for people and Agents to build together. We want issues,
pull requests, discussions, and community spaces to stay useful, direct, and
respectful.
## Expected Behavior
- Be clear, constructive, and respectful when giving feedback.
- Assume good intent while keeping discussions focused on facts, tradeoffs, and outcomes.
- Respect different experience levels, backgrounds, languages, and working styles.
- Keep disagreements technical, specific, and connected to the project.
- Help maintainers keep issues and pull requests focused.
- Protect private prompts, credentials, customer information, and other sensitive data.
## Unacceptable Behavior
The following behavior is not acceptable in Meldwork project spaces:
- Personal attacks, insults, harassment, or sustained disruption.
- Demeaning comments about identity, background, language, ability, or experience.
- Threats, intimidation, stalking, or unwanted contact.
- Publishing another person's private information without consent.
- Spam, trolling, discrimination, or repeated off-topic posting.
- Submitting malicious code, secrets, private data, or intentionally misleading reports.
- Any behavior maintainers reasonably judge to make the project unsafe or unproductive.
## Maintainer Responsibilities
Maintainers may edit or remove comments, close issues, limit participation, or
block participants when needed to protect the project community. Enforcement
should be proportional, documented when practical, and focused on keeping
collaboration healthy.
## Reporting
Report conduct concerns privately through the repository's GitHub contact or
security reporting channel. Do not open a public issue for sensitive reports.
Please include:
- a short description of what happened;
- links, screenshots, or message references when available;
- whether you want follow-up, confidentiality, or no direct reply.
Reports will be reviewed by project maintainers. We will handle reports with
care and limit access to people who need the information to respond.
This Code of Conduct applies wherever the project or its maintainers represent
the Meldwork community. It is adapted from the Contributor Covenant and the
community standards used by comparable open-source projects.
+12 -18
View File
@@ -1,23 +1,17 @@
# Commercial use / 商业使用
# Commercial Use And Licensing
Meldwork's original source code, documentation, README files, and official website
are licensed under [Apache License 2.0](LICENSE). Commercial use, private use,
modification, redistribution, and inclusion in paid products or services are
permitted under its terms. No separate commercial authorization or license fee
is required.
Meldwork is source-available under the [Meldwork Non-Commercial Source License](LICENSE). The public repository can be inspected, evaluated, run, and modified for non-commercial purposes, but it is not licensed for commercial use.
When redistributing, preserve the license, applicable copyright and attribution
notices, and NOTICE information, and identify modified files as required by the
license. Apache-2.0 does not grant trademark rights (Section 6).
Any commercial use requires prior written permission from the copyright holder. This includes monetizing Meldwork, reselling or hosting it, embedding it in a proprietary product, using it to provide paid services, or using it as part of a for-profit organization's business operations.
Third-party dependencies, fonts, Agent names, and logos retain their own licenses
and terms; their inclusion does not imply endorsement. In particular, the
website's bundled Fusion Pixel font retains its OFL license and notices.
The copyright holder may offer a separate commercial license for organizations that need permission for:
Meldwork 原创源代码、文档、README 和官网均采用 Apache-2.0。依照许可证条款,
允许商业使用、私人使用、修改、再分发,以及用于收费产品或服务;无需另行取得商业
授权或支付许可费。再分发时应保留许可证、适用的版权与归属声明及 NOTICE 信息,并
按许可证要求标明修改。Apache-2.0 第 6 条不授予商标权。第三方依赖、字体、Agent
名称和标志仍遵循各自条款;官网附带的 Fusion Pixel 字体保留 OFL 许可证与声明。
- proprietary redistribution or resale of Meldwork or a modified version;
- embedding Meldwork code in a closed-source product;
- offering a hosted, managed, or network-accessible version;
- internal business operations at a for-profit organization;
- separately licensed commercial features, enterprise integrations, support, or managed deployment.
This summary does not replace LICENSE. 本说明不替代 LICENSE 正文。
A commercial license does not change the licenses of third-party dependencies, Agent products, names, models, or logos. The repository license also does not grant trademark rights to the Meldwork name, logo, screenshots, or other brand assets.
This page is a practical summary and does not replace the license text or legal advice. When a planned use may be commercial, contact the repository owner through GitHub before using, distributing, hosting, reselling, or integrating the product.
+6 -7
View File
@@ -1,19 +1,18 @@
# Contributing
Meldwork is licensed under the [Apache License 2.0](LICENSE). Contributions are welcome when they improve the local-first desktop product, documentation, tests, or security posture.
Meldwork is source-available under the [Meldwork Non-Commercial Source License](LICENSE). Contributions are welcome when they improve the local-first desktop product, documentation, tests, or security posture.
Unless a separate written agreement applies, a contribution accepted into this repository is available under the repository license. Opening a pull request does not transfer copyright beyond the rights granted by the Apache License 2.0.
Unless a separate written agreement applies, a contribution accepted into this repository is available under the repository license. The project may also offer separately licensed commercial editions. Before external code is incorporated into a separately licensed commercial build, the maintainer may require a reviewed contributor-license agreement that grants the necessary relicensing rights. Opening a pull request by itself does not transfer copyright or silently grant those additional rights.
## Ground Rules
- 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, credential, or executable-path access.
- Preserve the local-first boundary: renderer code must not gain unrestricted filesystem, shell, Provider 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.
- Confirm that all submitted code and assets are yours to license and do not introduce incompatible license obligations.
## Community Standards
## Commercial Editions
Please follow the [Code of Conduct](CODE_OF_CONDUCT.md) in issues, pull
requests, discussions, and other project spaces.
Commercial use of Meldwork requires prior written permission. Commercial editions, proprietary integrations, support, and other paid offerings may use separate terms; see [COMMERCIAL_USE.md](COMMERCIAL_USE.md).
+58 -177
View File
@@ -1,201 +1,82 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Meldwork Non-Commercial Source License 1.0
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
Copyright (c) 2026 Ryder-MHumble.
1. Definitions.
This license applies to Meldwork and the source code, documentation, assets, and
other materials in this repository, except where a file or third-party notice
states a different license.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
Meldwork is source-available, not open source in the OSI sense. Commercial use is
not permitted without prior written permission from the copyright holder.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
1. Allowed non-commercial use
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
You may download, inspect, run, copy, and modify Meldwork for personal learning,
research, evaluation, security review, and other non-commercial purposes.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
You may share unmodified or modified copies for non-commercial purposes if you:
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
- keep this license and all copyright, attribution, and trademark notices;
- clearly mark your changes when you distribute a modified copy;
- do not remove or obscure third-party notices; and
- do not imply that the copyright holder endorses your copy or service.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
2. Commercial use requires permission
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
You may not use Meldwork for a commercial purpose without a separate written
license or other written permission from the copyright holder.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
Commercial purpose includes, without limitation:
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
- selling, renting, sublicensing, or otherwise monetizing Meldwork or a modified
version;
- offering Meldwork or a modified version as a hosted, managed, consulting,
outsourcing, integration, or support service;
- embedding Meldwork code, UI, workflow, or packaged binaries in a proprietary,
paid, or revenue-generating product;
- using Meldwork to provide paid deliverables, paid automation, paid Agent
operations, or other business services to a customer; and
- use by or for a for-profit company, commercial product team, or revenue-driven
organization as part of its business operations.
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
If you want to use Meldwork commercially, contact the repository owner and obtain
written permission before using, distributing, hosting, reselling, or integrating
the product.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. No trademark license
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
This license does not grant rights to use the Meldwork name, logo, product
identity, screenshots, or other brand assets except as needed to describe an
unaltered copy of the software in a truthful, non-confusing way.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
4. Third-party materials
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
Third-party software, Agent products, models, names, logos, dependencies, and
other materials remain subject to their own licenses and terms. This license does
not give you rights that the copyright holder does not own.
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
5. Contributions
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
Unless a separate written agreement says otherwise, contributions submitted to
this repository may be used under this license. The copyright holder may require
a separate contributor agreement before accepting contributions intended for a
commercially licensed edition.
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
6. Termination
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
If you violate this license, your rights under it terminate automatically. The
copyright holder may reinstate rights in writing after the violation is cured.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
7. No warranty
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
Meldwork is provided "as is", without warranties or conditions of any kind,
including implied warranties of merchantability, fitness for a particular
purpose, title, and non-infringement. You are responsible for evaluating whether
the software is suitable and lawful for your use.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of liability
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
To the maximum extent permitted by law, the copyright holder and contributors
will not be liable for any damages arising from or related to the software or
this license, including direct, indirect, incidental, consequential, special,
exemplary, punitive, or business-interruption damages.
+3 -5
View File
@@ -1,7 +1,9 @@
Meldwork
Copyright 2026 Ryder-MHumble and Meldwork contributors.
Meldwork is licensed under the Apache License 2.0. See LICENSE.
Meldwork is licensed under the Meldwork Non-Commercial Source License. See
LICENSE. Commercial use requires separate written permission from the copyright
holder.
Third-party Agent names, product names, and logos remain the property of their
respective owners. They are used only to identify compatible integrations and
@@ -10,7 +12,3 @@ do not imply sponsorship or endorsement.
The OpenCodeReview logo is sourced from Alibaba's OpenCodeReview project:
https://github.com/alibaba/open-code-review/blob/main/imgs/logo-core.svg
The upstream project is licensed under Apache License 2.0.
The Zcode logo is sourced unchanged from the official Zcode website:
https://zcode.z.ai/favicon-192x192.png?v=20260707-transparent
The logo identifies the integration; its trademark rights remain with its owner.
+80 -64
View File
@@ -1,3 +1,11 @@
<!-- <p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="frontend/public/logos/meldwork-wordmark-v3-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="frontend/public/logos/meldwork-wordmark-v3.svg">
<img src="frontend/public/logos/meldwork-wordmark-v3.svg" alt="Meldwork" width="360">
</picture>
</p> -->
<p align="center">
<img src="frontend/public/logos/meldwork-readme-banner-en.png" alt="Meldwork README banner" width="100%">
</p>
@@ -6,93 +14,109 @@
<strong>English</strong> · <a href="README.zh-CN.md">简体中文</a>
</p>
# Meldwork — Let Agents collaborate to move tasks forward
# Meldwork builds the organization layer for AI agents.
**Set the goal. Let Agents work together. Meldwork brings your existing Agents into one working group to share context, discuss approaches, carry out work, and check each other's results — with less relaying, prompting, and stitching outputs together.**
**Most tools help humans run more agents. Meldwork is built for a different job: make multi-agent work legible, accountable, and reusable.**
Meldwork is a local-first multi-Agent collaboration workspace for the Agent tools you already use on your Mac. Once you choose the participants, goal, context, and permissions, Agents in Auto Discussion can build on each other's work, request contributions or reviews, and advance the task within the authorized scope. Direct conversations and independent concurrent responses also support research, analysis, writing, planning, review, and implementation.
You do not need to direct every round, but you still set the goal, grant permissions, and make key decisions. Missing information, permission requests, or tradeoffs may require your input. Workspace writes are opt-in; collaboration history, run state, and deliverables stay local so you can revisit the work and continue it.
The current preview is a local work cell for supported Agent CLIs. It keeps participants explicit, context scoped, collaboration bounded, run state inspectable, and human review in the loop. Selected Agents can now respond concurrently or move through independent proposals, peer negotiation, responsibility-based work, single-writer integration, and independent verification around one goal.
<p align="center">
<a href="https://github.com/Ryder-Sun/Meldwork/releases/download/Meldwork-V1.0.9/Meldwork-1.0.9-arm64.dmg"><strong>Download Meldwork V1.0.9 for Apple silicon macOS</strong></a>
<a href="https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.3/Meldwork-0.1.3-arm64.dmg"><strong>Download Meldwork V1.0.3 for Apple silicon macOS</strong></a>
· <a href="architecture.md">Architecture</a>
· <a href="LICENSE">License</a>
</p>
## What Meldwork is for
## Who it is for
Meldwork is useful when several Agents need to advance the same task and you want to spend less time passing context and coordinating next steps. Typical work includes:
Use Meldwork when you:
- research and information synthesis across local Agent tools and selected knowledge sources;
- product, market, operational, and technical analysis;
- writing, planning, review, and structured document work;
- multimodal work with local documents, images, audio, video, PDFs, and code or configuration files;
- implementation in an authorized workspace, with other Agents checking the deliverables and requesting revisions.
- already have multiple local Agent CLIs and need one task to span them;
- need independent review for code, research, or product analysis before choosing a path;
- need inspectable traces, evidence, and human review before workspace writes.
## How a task moves through Meldwork
## Workflow
1. **Select** the Agents that will work on the task together.
2. **Scope** the goal, working directory, context, attachments, Skills, knowledge sources, and permissions.
3. **Collaborate** in Auto Discussion: let Agents discuss approaches, carry out authorized work, and check results. Direct conversations and Concurrent Responses are also available.
4. **Decide** when the work needs information, permission, or a tradeoff resolved.
5. **Check the result** and request changes when more work is needed.
1. **Select** the local Agents and participants.
2. **Scope** the goal, working directory, context, and permissions.
3. **Run** Direct, Concurrent Responses, or Auto Discussion V4.
4. **Review and adopt** the result, evidence, and any Human Gate before changing the workspace.
## Collaboration modes
| Mode | What happens | Best for |
| --- | --- | --- |
| **Direct** | One selected Agent keeps its conversation and native session when supported. | Focused research, writing, analysis, or execution. |
| **Concurrent Responses** | Selected Agents receive the same frozen task snapshot and return independent responses in stable order. | Comparing perspectives before choosing a path. |
| **Auto Discussion V4** | Agents propose independently, discuss, request peer contributions, carry out authorized work, and check deliverables. A delivery owner judges progress and requests user decisions when needed. Native sessions continue when supported. | Multi-step collaboration with less manual coordination. |
| **Direct** | One selected Agent keeps its conversation and native session when supported. | Focused work with one Agent. |
| **Concurrent Responses** | Selected Agents receive the same frozen task snapshot and return independent replies in stable order. | Comparing approaches before choosing one. |
| **Auto Discussion V4** | Selected Agents propose, challenge, negotiate responsibilities, execute dependency-aware work, then verify the result. | Multi-round work that needs explicit responsibility and review. |
Participants are selected by the user. Automatic selection from a larger roster is outside the current preview.
Participants are always selected by the user. Automatic selection from a larger roster is outside the current preview.
## Revisit the process and continue the work
## How Meldwork differs
Meldwork keeps task context, Agent replies, run state, and deliverables locally so you can see how work progressed, where it stalled, and what needs attention next. The Run Ledger preserves phase, participant, attempt, artifact, recovery, and Human Gate state. Diagnostic tool output and sensitive runtime details stay behind the Electron main-process boundary.
Meldwork is a local-first AI Agent workspace for multi-Agent review and decision traceability, not a terminal, cloud Agent fleet, communication network, or programmable orchestration framework. It connects the local Agent tools you already use to a decision-ready review workflow and keeps independent findings, evidence, responsibility, and the human adoption decision visible in one local work cell.
These records help you inspect collaboration; they do not establish that every claim has been fact-checked. Peer review does not guarantee correctness, and important deliverables still need acceptance checks appropriate to their use.
| Project | Category | What it focuses on | Meldwork's difference |
| --- | --- | --- | --- |
| [Buzz](https://github.com/block/buzz) | Agent communication network | Identity, channels, events, and persistent collaboration. | Case-scoped independent judgments with evidence-backed Decision and Disposition. |
| [Pragma](https://github.com/pqpo/pragma) | Method and workflow runtime | Reusable Expert, Flow, Memory, Evaluation, and DSL assets. | Keeps disagreement, re-checks, and adoption records visible before a workflow is fixed. |
| [Munder Difflin](https://github.com/chaitanyagiri/munder-difflin) | Visual Agent company | Boss -> Manager -> Workers, with task and activity status. | Tracks Finding -> Evidence -> Decision -> Disposition without hiding dissent behind a central manager. |
| [Superset](https://github.com/superset-sh/superset) / [Conductor](https://www.conductor.build/) / [Vibe Kanban](https://github.com/BloopAI/vibe-kanban) | Parallel coding workspaces | Parallel coding Agents, isolated workspaces, diffs, and merges. | Local-first review across heterogeneous CLIs, not terminal count, worktree throughput, or cloud sandboxes. |
| [cmux](https://github.com/manaflow-ai/cmux) | Agent-native terminal | Low-friction terminals, notifications, and multitasking. | Adds frozen context, independent results, and a human adoption gate. |
| [Nimbalyst](https://github.com/nimbalyst/nimbalyst) | Agent and artifact workspace | Parallel Agents with Markdown, mockups, and diagrams. | Places evidence and accountable decisions alongside artifacts. |
| [Paperclip](https://github.com/paperclipai/paperclip) | Agent organization management | Companies, roles, budgets, approvals, and org charts. | Grounds responsibility in real findings and outcomes, not a virtual company. |
| [MCP Agent Mail](https://github.com/Dicklesworthstone/mcp_agent_mail) | Agent communication infrastructure | Identity, inboxes, threads, and advisory file leases. | Preserves Case and review semantics above the transport layer. |
| LangGraph / CrewAI / AutoGen / OpenAI Agents SDK / Google ADK | Programmable multi-Agent frameworks | Graphs, roles, routing, tools, and approvals for developers. | Delivers a desktop review workflow without requiring teams to build an orchestration framework. |
| Warp Oz / Devin / Factory / OpenHands Cloud | Cloud Agent control planes | Hosted sandboxes, remote execution, and Agent fleets. | Runs in the user's local Electron work cell with existing CLIs and local data boundaries. |
## General Agent catalog
Meldwork detects and invokes compatible locally installed Agent commands. The catalog currently includes:
## See it in action
<table>
<tr>
<td align="center" valign="top"><img src="frontend/public/agent-logos/codex.svg" alt="Codex logo" width="32" height="32"><br><strong>Codex</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/hermes.png" alt="Hermes logo" width="32" height="32"><br><strong>Hermes</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/openclaw-transparent.png" alt="OpenClaw logo" width="32" height="32"><br><strong>OpenClaw</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/workbuddy.png" alt="WorkBuddy logo" width="32" height="32"><br><strong>WorkBuddy</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/zcode.png" alt="Zcode logo" width="32" height="32"><br><strong>Zcode</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/pi.svg" alt="Pi Agent logo" width="32" height="32"><br><strong>Pi Agent</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/kimi.png" alt="Kimi Code logo" width="32" height="32"><br><strong>Kimi Code</strong></td>
<th>Local Agent discovery</th>
<th>Multi-agent review</th>
<th>Direct multimodal work</th>
</tr>
<tr>
<td align="center" valign="top"><img src="frontend/public/agent-logos/mimo.svg" alt="MiMo Code logo" width="32" height="32"><br><strong>MiMo Code</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/claude.png" alt="Claude Code logo" width="32" height="32"><br><strong>Claude Code</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/gemini.svg" alt="Gemini CLI logo" width="32" height="32"><br><strong>Gemini CLI</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/opencode.svg" alt="OpenCode logo" width="32" height="32"><br><strong>OpenCode</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/qwen.svg" alt="Qwen Code logo" width="32" height="32"><br><strong>Qwen Code</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/opencodereview.svg" alt="OpenCodeReview logo" width="32" height="32"><br><strong>OpenCodeReview</strong></td>
<td align="center"><a href="assets/meldwork-agent-discovery.png"><img src="assets/meldwork-agent-discovery.png" alt="Meldwork local-first multi-agent workspace detecting Agent CLIs" width="320" height="205"></a></td>
<td align="center"><a href="assets/meldwork-multi-agent-review.png"><img src="assets/meldwork-multi-agent-review.png" alt="Meldwork multi-agent collaboration with evidence and human review" width="320" height="205"></a></td>
<td align="center"><a href="assets/meldwork-direct-multimodal.png"><img src="assets/meldwork-direct-multimodal.png" alt="Meldwork direct Agent workspace with local files and media" width="320" height="205"></a></td>
</tr>
</table>
Approved Agent Connectors and custom executable Agents use the desktop connector paths. The [desktop guide](desktop/README.md) contains the current adapter, installation, provider, and capability matrix.
Example: give Codex, Claude Code, and Gemini CLI the same change-review context, compare their independent findings, then adopt only the evidence-backed result you approve.
## Context and local boundaries
## Supported local Agent CLIs
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.
Meldwork detects and invokes an installed command when its adapter and the CLI version are compatible:
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.
<table>
<tr>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/codex.svg" alt="Codex logo" width="32" height="32"><br><strong>Codex</strong></td>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/hermes.png" alt="Hermes logo" width="32" height="32"><br><strong>Hermes</strong></td>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/openclaw-transparent.png" alt="OpenClaw logo" width="32" height="32"><br><strong>OpenClaw</strong></td>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/workbuddy.png" alt="WorkBuddy logo" width="32" height="32"><br><strong>WorkBuddy</strong></td>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/pi.svg" alt="Pi Agent logo" width="32" height="32"><br><strong>Pi Agent</strong></td>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/kimi.png" alt="Kimi Code logo" width="32" height="32"><br><strong>Kimi Code</strong></td>
</tr>
<tr>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/mimo.svg" alt="MiMo Code logo" width="32" height="32"><br><strong>MiMo Code</strong></td>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/claude.png" alt="Claude Code logo" width="32" height="32"><br><strong>Claude Code</strong></td>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/gemini.svg" alt="Gemini CLI logo" width="32" height="32"><br><strong>Gemini CLI</strong></td>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/opencode.svg" alt="OpenCode logo" width="32" height="32"><br><strong>OpenCode</strong></td>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/qwen.svg" alt="Qwen Code logo" width="32" height="32"><br><strong>Qwen Code</strong></td>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/opencodereview.svg" alt="OpenCodeReview logo" width="32" height="32"><br><strong>OpenCodeReview</strong></td>
</tr>
</table>
## Download V1.0.9
Approved Agent Connectors can be added through the [Agent Connector SDK](docs/agent-connector-sdk.md); custom executable Agents use the desktop custom-Agent path. See the [desktop guide](desktop/README.md) for the full adapter and capability matrix.
For Apple silicon macOS, download the [Meldwork V1.0.9 prerelease](https://github.com/Ryder-Sun/Meldwork/releases/tag/Meldwork-V1.0.9) and choose [`Meldwork-1.0.9-arm64.dmg`](https://github.com/Ryder-Sun/Meldwork/releases/download/Meldwork-V1.0.9/Meldwork-1.0.9-arm64.dmg). The release also includes a ZIP archive and SHA-256 manifest. Install at least one supported local Agent CLI before opening the app.
## Quick start
The preview artifacts are ad-hoc signed and not notarized with an Apple Developer ID. macOS may require **Open Anyway** under **System Settings → Privacy & Security** on first launch.
### Apple silicon macOS preview
## Run from source
Download [`Meldwork-0.1.3-arm64.dmg`](https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.3/Meldwork-0.1.3-arm64.dmg) from the [official V1.0.3 prerelease](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.3), move Meldwork to Applications, and install at least one supported local Agent CLI. The prerelease is ad-hoc signed and not notarized; macOS may require **Open Anyway** in **System Settings -> Privacy & Security** on first launch.
### Run from source
Prerequisites: Node.js `22.12+` and npm.
@@ -111,27 +135,19 @@ npm --prefix frontend run build:desktop
npm --prefix desktop test
```
## V1.0.9
## Current boundary
Auto Discussion can complete native Agent turns on explicit completion and requires each participant to approve the same delivery version. A real six-Agent unlimited discussion completed in nine rounds with matching delivery hashes. This preview also improves Agent authentication status, handles CLI authentication errors reported on stderr, and bundles knowledge-base logos.
Meldwork is a local Electron app, not a hosted Agent fleet or a general Agent framework. The current preview does not provide remote/cloud/channel Agent execution, automatic participant selection, enterprise SSO/RBAC/governance, or an Outcome Network. Local-first does not mean fully offline: a selected Agent may send prompts, attachments, or Skills to its configured Provider. Workspace writes are opt-in workflow controls, not an operating-system sandbox.
Agent installation does not guarantee a working model connection: OpenClaw's isolated runtime may not support the native environment configuration, WorkBuddy's CLI may require its own login, and Zcode needs valid native provider credentials. Desktop and frontend regression checks passed 1,700 and 379 tests respectively.
## V1.0.8
V1.0.8 keeps Kimi read-only queries in manual approval mode instead of forcing a planning cycle, preserves MiMo's final text chunks, forwards supported native CLI configuration, and retains the selected Auto Discussion mode after sending. Repetitive native output is cancelled with partial replies preserved. Zcode remains before Pi Agent with its official logo and native default configuration; WorkBuddy can reuse a configured desktop model selected in Meldwork.
Real CLI acceptance covered all 13 installed Agents. A five-Agent unlimited discussion completed all 14 native calls through an isolated Magpie hotfix. The installed Magpie version can rewrite `stop` into `tool_calls` when `tool_calls` is null, causing OpenCode, MiMo, and Hermes to continue indefinitely; this Meldwork release does not install or restart Magpie. OpenClaw, Zcode, Gemini, Qwen, and OpenCodeReview also had native configuration or authentication failures in this audit. Detecting an installed CLI does not certify a working model connection. Regression validation includes 1,655 desktop tests, 370 frontend tests, web and desktop builds, and package validation. Unlimited discussion retains stalled-process detection and write permissions remain opt-in.
Electron acceptance with the same isolated gateway also completed a separate five-Agent unlimited discussion: 19 native calls, no failed attempts or retries, about 12 minutes. Auto Discussion and No round limit remained selected after sending, completion, and leaving and reopening the group. This verifies the workflow, not a response-time guarantee or a repair to the user's installed gateway.
## Documentation
## Docs
- [Architecture and product boundary](architecture.md)
- [Desktop setup and Agent matrix](desktop/README.md)
- [Agent Connector SDK](docs/agent-connector-sdk.md)
- [AI discoverability index](docs/ai-discoverability.md)
- [Contributing](CONTRIBUTING.md)
- [Security policy](SECURITY.md)
## License
Meldwork is open source under the [Apache License 2.0](LICENSE). Commercial use, private use, modification, and redistribution are permitted under the license terms. No separate commercial authorization or license fee is required. Apache-2.0 does not grant trademark rights; see [COMMERCIAL_USE.md](COMMERCIAL_USE.md).
Meldwork is source-available under the [Meldwork Non-Commercial Source License 1.0](LICENSE). You may inspect, run, modify, and share it for non-commercial purposes. Commercial use requires prior written permission; see [COMMERCIAL_USE.md](COMMERCIAL_USE.md).
+79 -63
View File
@@ -1,3 +1,11 @@
<!-- <p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="frontend/public/logos/meldwork-wordmark-v3-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="frontend/public/logos/meldwork-wordmark-v3.svg">
<img src="frontend/public/logos/meldwork-wordmark-v3.svg" alt="Meldwork" width="360">
</picture>
</p> -->
<p align="center">
<img src="frontend/public/logos/meldwork-readme-banner-cn.png" alt="Meldwork README banner" width="100%">
</p>
@@ -6,93 +14,109 @@
<a href="README.md">English</a> · <strong>简体中文</strong>
</p>
# Meldwork:让 Agent 自主协作推进任务
# Meldwork 为 AI Agent 构建组织层
**提出目标,让 Agent 们协作完成。Meldwork 把你已有的 Agent 组织成一个工作小组,共享上下文、讨论方案、协作执行并相互检查,减少你在它们之间转述、催办和整合结果的工作。**
**多数工具是在帮人类管更多 Agent;Meldwork 解决的是另一件事:让多 Agent 协作变得可见、可追责、可复用。**
Meldwork 是一个本地优先的多 Agent 协作工作空间,用来连接你已经在电脑上使用的 Agent 工具。你选定参与者、目标、上下文和权限后,自动讨论中的 Agent 可以接续彼此的工作,请求同伴补充或检查,并在授权范围内推进任务。它也支持单个 Agent 的直接会话和多个 Agent 的独立并发回复,覆盖研究、分析、写作、规划、评审和实施。
你无需安排每一轮发言,但仍负责目标、权限和关键决策。遇到信息缺失、权限请求或需要取舍的问题时,任务可能等待你处理。工作区写入需要明确授权;协作过程、运行状态和产物保存在本地,方便回看和继续工作。
当前预览版是一个面向已支持本地 Agent CLI 的本地工作单元。它把参与者、上下文、协作边界、运行状态和人类复核放在同一个工作空间里。已选 Agent 现在可以并发回复,也可以围绕同一目标先独立提案、再协商职责、分工执行、单写者整合并由其他 Agent 独立复核。
<p align="center">
<a href="https://github.com/Ryder-Sun/Meldwork/releases/download/Meldwork-V1.0.9/Meldwork-1.0.9-arm64.dmg"><strong>下载 Meldwork V1.0.9 Apple 芯片 macOS 版</strong></a>
<a href="https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.3/Meldwork-0.1.3-arm64.dmg"><strong>下载 Meldwork V1.0.3 Apple 芯片 macOS 版</strong></a>
· <a href="architecture.md">架构</a>
· <a href="LICENSE">许可证</a>
</p>
## Meldwork 适合什么工作
## 适合谁
当一个任务需要多个 Agent 共同推进,而你不想反复传递上下文、协调下一步时,Meldwork 更适合:
当你有以下需求时,Meldwork 更适合:
- 研究与信息综合:结合本地 Agent 工具和明确选择的知识源;
- 产品、市场、运营和技术分析;
- 写作、规划、评审和结构化文档工作;
- 处理本地文档、图片、音频、视频、PDF、代码和配置文件的多模态工作;
- 在授权工作目录内执行、由其他 Agent 检查成果并继续修改的实施工作。
- 已经安装多个本地 Agent CLI,希望让同一任务跨 Agent 协作;
- 需要在代码评审、研究或产品分析中获得独立判断,再选择方案;
- 需要在写入工作区前检查可追溯的运行轨迹、证据并保留人工复核。
## 一个任务如何在 Meldwork 中推进
## 工作流程
1. **选择**一起处理任务的 Agent。
2. **限定**目标、工作目录、上下文、附件、Skill、知识源和权限。
3. **协作**:启动 Auto Discussion,让 Agent 讨论方案、接续执行并检查成果;也可选择直接会话或并发回复。
4. **决策**:在需要补充信息、授权或取舍时介入。
5. **查看结果**:检查交付物,必要时提出修改,让工作继续推进。
1. **选择**本地 Agent 和参与者。
2. **限定**目标、工作目录、上下文和权限。
3. **运行**直接会话、并发回复或 Auto Discussion V4。
4. **复核并采用**结果、证据和待处理的人工审批门(Human Gate),再写入工作区。
## 协作模式
| 模式 | 运行方式 | 适合场景 |
| --- | --- | --- |
| **直接会话** | 一个已选 Agent 在适配器支持时保持对话和原生会话。 | 聚焦研究、写作、分析或执行。 |
| **并发回复** | 已选 Agent 收到同一份冻结任务快照,并按稳定顺序返回独立回复。 | 在选择方案前比较不同视角。 |
| **Auto Discussion V4** | Agent 独立提出方案后,接续讨论、请求同伴贡献、在授权范围内执行并检查成果。交付负责人判断任务进度,必要时请求用户决策;支持时保持原生会话。 | 希望减少人工协调的多步骤协作任务。 |
| **直接会话** | 一个已选 Agent 在适配器支持时保持对话和原生会话。 | 使用单个 Agent 完成聚焦工作。 |
| **并发回复** | 已选 Agent 收到同一份冻结任务快照,并按稳定顺序返回独立回复。 | 在选择方案前比较不同路径。 |
| **Auto Discussion V4** | 已选 Agent 先提案、质询并协商责任,按依赖执行工作,再独立复核结果。 | 需要明确责任和复核的多轮工作。 |
参与者始终由用户选择。从更大候选池自动选人组队不属于当前预览版。
参与者始终由用户选择。从更大候选池自动选人不属于当前预览版。
## 过程可回看,工作可继续
## Meldwork 与相邻产品的区别
Meldwork 在本地保留任务上下文、Agent 回复、运行状态和产物,方便了解任务如何推进、哪里遇到阻塞,以及接下来需要处理什么。Run Ledger 保留阶段、参与者、尝试、产物、恢复状态和人工审批门;诊断工具输出与敏感运行信息留在 Electron 主进程边界内。
Meldwork 是面向多 Agent 评审与决策可追溯性的本地优先 AI Agent 工作空间,不是终端、云端 Agent Fleet、通信网络或可编程编排框架。它把你已经在使用的本地 Agent 工具连接到可形成决定的评审流程,并在一个本地工作单元中保留独立发现、证据、责任和人工采用决定。
过程记录帮助检查协作情况,不代表每条结论都经过事实核验。Agent 的相互检查也不能保证结果正确,重要交付物仍需按实际用途验收。
| 项目 | 类别 | 主要解决什么 | Meldwork 的区别 |
| --- | --- | --- | --- |
| [Buzz](https://github.com/block/buzz) | Agent 通信网络 | 身份、频道、事件和持续协作。 | 面向单个 Case 的独立判断,以及有证据支持的 Decision 和 Disposition。 |
| [Pragma](https://github.com/pqpo/pragma) | 方法与工作流运行时 | 可复用的 Expert、Flow、Memory、Evaluation 和 DSL 资产。 | 在流程固定前保留分歧、复验和采用记录。 |
| [Munder Difflin](https://github.com/chaitanyagiri/munder-difflin) | 可视化 Agent 公司 | Boss -> Manager -> Workers,以及任务和活动状态。 | 追踪 Finding -> Evidence -> Decision -> Disposition,不让中心经理隐藏分歧。 |
| [Superset](https://github.com/superset-sh/superset) / [Conductor](https://www.conductor.build/) / [Vibe Kanban](https://github.com/BloopAI/vibe-kanban) | 并行编码工作台 | 并行 Coding Agent、隔离工作区、Diff 和 Merge。 | 面向异构 CLI 的本地优先评审,不以终端数量、Worktree 吞吐量或云 Sandbox 竞争。 |
| [cmux](https://github.com/manaflow-ai/cmux) | Agent 原生终端 | 低摩擦终端、通知和多任务处理。 | 增加冻结上下文、独立结果和人工采用门。 |
| [Nimbalyst](https://github.com/nimbalyst/nimbalyst) | Agent 与 Artifact 工作空间 | 并行 Agent,以及 Markdown、Mockup 和 Diagram。 | 在 Artifact 旁边保留证据和可追责的决定。 |
| [Paperclip](https://github.com/paperclipai/paperclip) | Agent 组织管理 | 公司、岗位、预算、审批和组织图。 | 将责任落在真实 Finding 和结果上,而不是虚拟公司。 |
| [MCP Agent Mail](https://github.com/Dicklesworthstone/mcp_agent_mail) | Agent 通信基础设施 | 身份、收件箱、Thread 和 advisory file lease。 | 在传输层之上保留 Case 和评审语义。 |
| LangGraph / CrewAI / AutoGen / OpenAI Agents SDK / Google ADK | 可编程多 Agent 框架 | 面向开发者的 Graph、角色、路由、工具和审批。 | 团队无需编写编排框架代码即可获得桌面评审流程。 |
| Warp Oz / Devin / Factory / OpenHands Cloud | 云端 Agent 控制面 | 托管 Sandbox、远程执行和 Agent Fleet。 | 在本地 Electron 工作单元中使用现有 CLI,并保持本地数据边界。 |
## General Agent 目录
当适配器和 CLI 版本兼容时,Meldwork 会检测并调用已安装的 Agent 命令。当前目录包括:
## 运行示例
<table>
<tr>
<td align="center" valign="top"><img src="frontend/public/agent-logos/codex.svg" alt="Codex logo" width="32" height="32"><br><strong>Codex</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/hermes.png" alt="Hermes logo" width="32" height="32"><br><strong>Hermes</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/openclaw-transparent.png" alt="OpenClaw logo" width="32" height="32"><br><strong>OpenClaw</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/workbuddy.png" alt="WorkBuddy logo" width="32" height="32"><br><strong>WorkBuddy</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/zcode.png" alt="Zcode logo" width="32" height="32"><br><strong>Zcode</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/pi.svg" alt="Pi Agent logo" width="32" height="32"><br><strong>Pi Agent</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/kimi.png" alt="Kimi Code logo" width="32" height="32"><br><strong>Kimi Code</strong></td>
<th>本地 Agent 检测</th>
<th>多 Agent 评审</th>
<th>直接会话中的多模态工作</th>
</tr>
<tr>
<td align="center" valign="top"><img src="frontend/public/agent-logos/mimo.svg" alt="MiMo Code logo" width="32" height="32"><br><strong>MiMo Code</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/claude.png" alt="Claude Code logo" width="32" height="32"><br><strong>Claude Code</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/gemini.svg" alt="Gemini CLI logo" width="32" height="32"><br><strong>Gemini CLI</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/opencode.svg" alt="OpenCode logo" width="32" height="32"><br><strong>OpenCode</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/qwen.svg" alt="Qwen Code logo" width="32" height="32"><br><strong>Qwen Code</strong></td>
<td align="center" valign="top"><img src="frontend/public/agent-logos/opencodereview.svg" alt="OpenCodeReview logo" width="32" height="32"><br><strong>OpenCodeReview</strong></td>
<td align="center"><a href="assets/meldwork-agent-discovery.png"><img src="assets/meldwork-agent-discovery.png" alt="Meldwork 本地优先多 Agent 工作空间检测 Agent CLI" width="320" height="205"></a></td>
<td align="center"><a href="assets/meldwork-multi-agent-review.png"><img src="assets/meldwork-multi-agent-review.png" alt="Meldwork 多 Agent 协作、证据与人工复核" width="320" height="205"></a></td>
<td align="center"><a href="assets/meldwork-direct-multimodal.png"><img src="assets/meldwork-direct-multimodal.png" alt="Meldwork 直接 Agent 工作空间处理本地文件和媒体" width="320" height="205"></a></td>
</tr>
</table>
已批准的 Agent Connector 和自定义可执行 Agent 使用桌面端的连接器入口。完整的适配器、安装、Provider 和能力矩阵见[桌面端指南](desktop/README.md)。
示例:把同一份变更评审上下文交给 Codex、Claude Code 和 Gemini CLI,比较各自的独立发现,再只采用你批准且有证据支持的结果。
## 上下文与本地边界
## 支持的本地 Agent CLI
Meldwork 作为本地 Electron 应用运行。对话、群组配置、运行记录和应用管理的附件保存在本地用户数据目录。渲染进程只通过受约束的 preload API 获取校验后的状态;可执行路径、凭据、原生会话引用、Skill 路径和任意 Shell 权限都留在主进程中。
当适配器和 CLI 版本兼容时,Meldwork 会检测并调用已安装的命令:
选定的 Agent 仍可能把 Prompt、附件或 Skill 发送到其自身 CLI 凭据对应的模型服务商。知识访问必须明确选择并受边界约束:当前实现支持本地 Obsidian 检索,以及由本地 CLI 管理的飞书或钉钉访问模式。Local-first 描述的是 Meldwork 的数据和协作位置,不代表 Agent 使用的模型服务商一定离线。
<table>
<tr>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/codex.svg" alt="Codex logo" width="32" height="32"><br><strong>Codex</strong></td>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/hermes.png" alt="Hermes logo" width="32" height="32"><br><strong>Hermes</strong></td>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/openclaw-transparent.png" alt="OpenClaw logo" width="32" height="32"><br><strong>OpenClaw</strong></td>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/workbuddy.png" alt="WorkBuddy logo" width="32" height="32"><br><strong>WorkBuddy</strong></td>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/pi.svg" alt="Pi Agent logo" width="32" height="32"><br><strong>Pi Agent</strong></td>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/kimi.png" alt="Kimi Code logo" width="32" height="32"><br><strong>Kimi Code</strong></td>
</tr>
<tr>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/mimo.svg" alt="MiMo Code logo" width="32" height="32"><br><strong>MiMo Code</strong></td>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/claude.png" alt="Claude Code logo" width="32" height="32"><br><strong>Claude Code</strong></td>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/gemini.svg" alt="Gemini CLI logo" width="32" height="32"><br><strong>Gemini CLI</strong></td>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/opencode.svg" alt="OpenCode logo" width="32" height="32"><br><strong>OpenCode</strong></td>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/qwen.svg" alt="Qwen Code logo" width="32" height="32"><br><strong>Qwen Code</strong></td>
<td align="center" valign="top" width="16.66%"><img src="frontend/public/agent-logos/opencodereview.svg" alt="OpenCodeReview logo" width="32" height="32"><br><strong>OpenCodeReview</strong></td>
</tr>
</table>
## 下载 V1.0.9
已批准的 Agent Connector 可通过 [Agent Connector SDK](docs/agent-connector-sdk.md) 接入;自定义可执行 Agent 使用桌面端的自定义 Agent 入口。完整适配器和能力矩阵见[桌面端指南](desktop/README.md)。
Apple 芯片 macOS 用户请前往 [Meldwork V1.0.9 预发布页](https://github.com/Ryder-Sun/Meldwork/releases/tag/Meldwork-V1.0.9),下载 [`Meldwork-1.0.9-arm64.dmg`](https://github.com/Ryder-Sun/Meldwork/releases/download/Meldwork-V1.0.9/Meldwork-1.0.9-arm64.dmg)。发布页同时提供 ZIP 压缩包和 SHA-256 校验文件。启动应用前,请至少安装一个受支持的本地 Agent CLI。
## 快速开始
该预览版安装包使用 ad-hoc 临时签名,未使用 Apple Developer ID 签名,也未进行公证。macOS 首次启动时可能需要在“系统设置 → 隐私与安全性”中选择“仍要打开 / Open Anyway”。
### Apple 芯片 macOS 预览版
## 从源码运行
从[官方 V1.0.3 预发布页](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.3)下载 [`Meldwork-0.1.3-arm64.dmg`](https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.3/Meldwork-0.1.3-arm64.dmg),将 Meldwork 移入“应用程序”,并至少安装一个受支持的本地 Agent CLI。该预发布版使用 ad-hoc 临时签名且未公证;首次启动时,macOS 可能要求你在“系统设置 -> 隐私与安全性”中选择“仍要打开 / Open Anyway”。
### 从源码运行
前置条件:Node.js `22.12+` 和 npm。
@@ -111,27 +135,19 @@ npm --prefix frontend run build:desktop
npm --prefix desktop test
```
## V1.0.9
## 当前边界
自动讨论可根据原生 Agent 的明确完成信号结束发言,并要求所有参与者认可同一版交付物。一轮真实六 Agent 无限轮讨论在九轮后完成,交付物哈希一致。此预览版还改进了 Agent 认证状态、识别 CLI 在标准错误中报告的认证失败,并将知识库图标打包进应用。
已安装 Agent 不代表模型连接可用:OpenClaw 的隔离环境可能无法使用现有原生配置,WorkBuddy CLI 可能需要单独登录,Zcode 需要有效的原生提供方凭据。桌面端和前端回归测试分别通过 1,700 项和 379 项。
## V1.0.8
V1.0.8 将 Kimi 只读问答改为手动权限模式,避免强制进入计划流程;修复 MiMo 末尾文本丢失、原生 CLI 配置变量遗漏,以及发送后 Auto Discussion 模式重置。原生输出持续重复时会取消调用并保留已输出内容。Zcode 保持官方标志和 Pi Agent 前的位置,沿用原生默认配置;WorkBuddy 可以复用在 Meldwork 中选定的桌面已配置模型。
本轮对全部 13 个已安装 CLI 进行了真实调用。使用独立 Magpie 热修网关的五 Agent 无限轮讨论中,14 次原生调用全部完成。当前安装的 Magpie 可能把 `tool_calls:null` 误判为真实工具调用,将 `stop` 改写为 `tool_calls`,导致 OpenCode、MiMo 和 Hermes 持续续写;本次 Meldwork 发布不会安装或重启 Magpie。OpenClaw、Zcode、Gemini、Qwen 和 OpenCodeReview 在本机另有原生配置或认证故障,检测到安装不代表模型连接已可用。回归验证包括桌面测试 1,655 项、前端测试 370 项、Web 与桌面构建及打包验证。无限轮讨论保留进程停滞检测,工作区写入仍需明确授权。
Electron 实机界面另完成一轮五 Agent 无限轮讨论:19 次原生调用全部成功,无失败或自动重试,用时约 12 分钟。发送、完成、切回首页再返回群聊后,自动讨论与不限轮数均保持选中。该验收同样使用独立热修网关,证明流程可完成,不构成响应时长保证,也不代表用户现有网关已修复。
Meldwork 是本地 Electron 应用,不是托管式 Agent Fleet,也不是通用 Agent 框架。当前预览版不提供远程、云端或频道 Agent 执行,不提供自动选人组队、企业 SSO/RBAC/治理或 Outcome Network。Local-first 不等于完全离线:选定的 Agent 仍可能把 Prompt、附件或 Skill(技能)发送到其配置的 Provider(模型服务商)。工作区写入是可选的工作流控制,不是操作系统级沙箱。
## 文档
- [架构与产品边界](architecture.md)
- [桌面端设置与 Agent 矩阵](desktop/README.md)
- [Agent Connector SDK](docs/agent-connector-sdk.md)
- [AI 可发现性索引](docs/ai-discoverability.md)
- [贡献指南](CONTRIBUTING.md)
- [安全策略](SECURITY.md)
## 许可证
Meldwork 以 [Apache License 2.0](LICENSE) 开源。在许可证条款范围内,可以商用、私用、修改和再分发。商用无需另行授权或支付许可费。Apache-2.0 不授予商标权;详见 [COMMERCIAL_USE.md](COMMERCIAL_USE.md)。
Meldwork 采用 [Meldwork 非商用源码许可 1.0](LICENSE)。你可以出于非商业目的查看、运行、修改和分享。商业使用需要事先书面许可,详见 [COMMERCIAL_USE.md](COMMERCIAL_USE.md)。
+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 runs each Agent with its own native CLI 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 manages Provider 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, CLI API keys, or Skill file paths to renderer code.
- Exposure of executable paths, attachment paths, native session references, Provider 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 failures that expose secrets.
- Credential storage or Provider configuration failures that expose secrets.
## Out of Scope
+19 -59
View File
@@ -2,7 +2,7 @@
## Product Boundary
Meldwork is a local Electron desktop product for multi-agent orchestration and review with explicitly selected Skill, image, and knowledge-source context. It has no account system, tenant model, required application server, remote conversation store, email delivery, scheduled jobs, or hosted web surface. Optional Agent, Cloud, and Channel Connectors run behind the Electron main-process trust boundary and are disabled until explicitly configured. The landing page at https://ryder-mhumble.github.io/Meldwork/ is the public marketing surface for search engine and AI assistant discoverability.
Meldwork is a local Electron desktop product for direct and multi-Agent work with explicitly selected Skill, image, and knowledge-source context. It has no account system, tenant model, required application server, remote conversation store, email delivery, scheduled jobs, or public SEO surface. Optional Agent, Cloud, and Channel Connectors run behind the Electron main-process trust boundary and are disabled until explicitly configured.
The supported product surface is the packaged Electron application. The frontend is a renderer bundle; without the local preload bridge it shows a desktop-required state and cannot operate the workspace.
@@ -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, 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` |
| 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` |
| 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` and its `local-workspace-auto-runner-*.cjs` parts (see Code Map) |
| 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` |
| 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, 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` |
| 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` |
| 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,53 +30,9 @@ 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.
@@ -89,11 +45,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 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.
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.
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. 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.
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.
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
@@ -105,6 +61,7 @@ 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 |
@@ -117,6 +74,7 @@ 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.
@@ -128,7 +86,6 @@ 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`).
@@ -138,7 +95,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`).
- Native Agent authentication and model requests are external dependencies; Meldwork cannot guarantee their availability or data handling.
- Provider requests and native Agent authentication 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`).
@@ -146,11 +103,14 @@ There is no Meldwork-hosted remote data store or application account namespace.
- No email sending, so there is no `emails.md`.
- No cron or scheduled background work, so there is no `cron.md`.
- No supported public or indexable routes in the app itself, so there is no `seo.md`. The landing page at https://ryder-mhumble.github.io/Meldwork/ serves as the public discoverability surface for search engines and AI assistants.
- No supported public or indexable routes, so there is no `seo.md`.
## Related Documents
- [Contributing](CONTRIBUTING.md)
- [Security Policy](SECURITY.md)
- [Repository Guidelines For Agents](AGENTS.md)
- [Changelog](CHANGELOG.md)
- [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)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

+20 -18
View File
@@ -13,15 +13,12 @@ API Key 或任意 Shell 执行能力。
- Hermes
- OpenClaw
- WorkBuddy
- Zcode
- Pi Agent
- Kimi Code
- MiMo Code
- Claude Code
- Gemini CLI
- OpenCode
- Qwen Code
- OpenCodeReview
Agent 管理面板提供经过白名单约束的安装入口。安装命令和下载地址由主进程固定,不会根据页面
输入执行任意命令。内置 Agent 通过各自支持的原生权限模式或 Meldwork 托管工具策略执行;无法
@@ -31,7 +28,7 @@ Agent 管理面板提供经过白名单约束的安装入口。安装命令和
## 首次启动与本地 Agent 检测
V1.0.7 GitHub 预发布使用 ad-hoc 临时签名,不使用 Apple Developer ID,也不提交 Apple 公证。
V1.0.3 GitHub 预发布使用 ad-hoc 临时签名,不使用 Apple Developer ID,也不提交 Apple 公证。
如果 macOS 首次启动时拦截候选应用,请打开
“系统设置 → 隐私与安全性”,找到 Meldwork 的拦截提示,点击“仍要打开 / Open Anyway”,
再确认启动。
@@ -69,29 +66,34 @@ Agent 原生支持的图片会按其数量限制直接传递。其他文件,
## Provider 配置
Agent 使用各自本机的原生鉴权、Provider 和默认模型配置。Meldwork 不要求统一 Provider,
也不会将一个 Agent 的密钥注入其他 Agent。WorkBuddy 桌面账号的加密登录不能直接供独立
CLI 解锁;可在 Meldwork 中选择桌面配置里已有且已配置鉴权的模型。
Meldwork 仅保存模型标识,运行时由主进程读取原生配置,不向渲染层返回密钥。
Zcode 沿用默认配置;若其默认 Provider 缺少 API Key,需要先修复本机原生配置。
用户可以在客户端内填写一个 OpenAI-compatible Provider:
```text
provider: Provider 显示名称
baseUrl: HTTPS 地址,或本机 loopback HTTP 地址
model: 模型名称
apiKey: API Key
```
API Key 仅通过 Electron `safeStorage` 加密保存到本机;操作系统安全存储不可用时拒绝落盘。
Provider 不设品牌、服务地址或模型默认值,也不会从远端自动获取配置。配置后可供 Hermes、
OpenClaw、WorkBuddy、Qwen 和 OpenCodeReview 使用;其他 Agent 沿用各自的本机鉴权和 Provider 配置。
## 本地 CLI 支持矩阵
| Agent | 检测命令 | macOS 安装 | Windows 安装 | 用户 Provider |
| --- | --- | --- | --- | --- |
| Codex | `codex` | npm:`@openai/codex@latest` | npm:`@openai/codex@latest` | 不注入;沿用 Codex 本机配置 |
| Hermes | `hermes` | 官方 `install.sh` | 官方 `install.ps1` | 沿用本机配置 |
| OpenClaw | `openclaw` | npm:`openclaw@latest` | npm:`openclaw@latest` | 沿用本机配置 |
| WorkBuddy | `codebuddy` | npm:`@tencent-ai/[email protected]` | npm:`@tencent-ai/[email protected]` | 可选择桌面已配置模型 |
| Zcode | `zcode` | 官方 CLI 安装 | 官方 CLI 安装 | 沿用默认配置 |
| Pi Agent | `pi` | npm:`@mariozechner/pi-coding-agent` | npm:`@mariozechner/pi-coding-agent` | 沿用本机配置 |
| Hermes | `hermes` | 官方 `install.sh` | 官方 `install.ps1` | 支持 |
| OpenClaw | `openclaw` | npm:`openclaw@latest` | npm:`openclaw@latest` | 支持,使用客户端隔离的托管配置 |
| WorkBuddy | `codebuddy` | npm:`@tencent-ai/[email protected]` | npm:`@tencent-ai/[email protected]` | 实验性支持 |
| Kimi Code | `kimi` | 官方 `install.sh` | 官方 `install.ps1` | 不注入;沿用 Kimi 本机配置 |
| MiMo Code | `mimo` | npm:`@mimo-ai/cli@latest` | npm:`@mimo-ai/cli@latest` | 不注入;沿用 MiMo 本机配置,按授权使用 `plan` / `build` 模式 |
| MiMo Code | `mimo` | npm:`@mimo-ai/cli@latest` | npm:`@mimo-ai/cli@latest` | 不注入;沿用 MiMo 本机配置,Meldwork 固定使用只读 `plan` Agent |
| Claude Code | `claude` | npm:`@anthropic-ai/claude-code@latest` | npm:`@anthropic-ai/claude-code@latest` | 不注入;沿用 Claude 本机配置 |
| Gemini CLI | `gemini` | npm:`@google/gemini-cli@latest` | npm:`@google/gemini-cli@latest` | 不注入;沿用 Gemini 本机配置 |
| OpenCode | `opencode` | npm:`opencode-ai@latest` | npm:`opencode-ai@latest` | 不注入;沿用 OpenCode 本机配置 |
| Qwen Code | `qwen` | npm:`@qwen-code/qwen-code@latest` | npm:`@qwen-code/qwen-code@latest` | 沿用本机配置 |
| OpenCodeReview | `ocr` | npm:`@alibaba-group/open-code-review@latest` | npm:`@alibaba-group/open-code-review@latest` | 沿用本机配置 |
| Qwen Code | `qwen` | npm:`@qwen-code/qwen-code@latest` | npm:`@qwen-code/qwen-code@latest` | 支持 |
| OpenCodeReview | `ocr` | npm:`@alibaba-group/open-code-review@latest` | npm:`@alibaba-group/open-code-review@latest` | 支持 |
macOS 会在当前 `PATH` 之外搜索 Volta、pnpm、fnm、asdf、mise、bun、
`~/.local/bin`、`~/.kimi-code/bin`、`~/.mimocode/bin`、Homebrew 等常见目录。Windows 会额外搜索用户 npm、
@@ -135,7 +137,7 @@ npm run dist
npm run dist:public
```
`pack` 用于生成未打包应用并验证 Electron 产物;`dist` 生成 V1.0.7 GitHub 预发布候选使用的 ad-hoc 签名 DMG 和 ZIP。这些产物不具备 Developer ID 信任链且未公证,不能声称通过 Gatekeeper。
`pack` 用于生成未打包应用并验证 Electron 产物;`dist` 生成 V1.0.3 GitHub 预发布候选使用的 ad-hoc 签名 DMG 和 ZIP。这些产物不具备 Developer ID 信任链且未公证,不能声称通过 Gatekeeper。
`dist:public` 是未来正式签名公开发行的专用命令:它要求
Developer ID 证书和完整公证凭据,强制代码签名,在签名后校验永久 Bundle ID、Team ID 与
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

+3 -3
View File
@@ -1,13 +1,13 @@
{
"name": "meldwork-desktop",
"version": "1.0.9",
"version": "0.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meldwork-desktop",
"version": "1.0.9",
"license": "Apache-2.0",
"version": "0.1.3",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@agentclientprotocol/sdk": "0.23.0",
"@streamparser/json": "0.0.22",
+10 -17
View File
@@ -1,10 +1,10 @@
{
"name": "meldwork-desktop",
"version": "1.0.9",
"version": "0.1.3",
"private": true,
"description": "A local-first desktop workspace for multi-Agent collaboration",
"author": "Ryder-MHumble",
"license": "Apache-2.0",
"license": "SEE LICENSE IN LICENSE",
"engines": {
"node": ">=22.12.0"
},
@@ -38,7 +38,6 @@
"productName": "Meldwork",
"artifactName": "Meldwork-${version}-${arch}.${ext}",
"afterPack": "scripts/after-pack.cjs",
"afterAllArtifactBuild": "scripts/after-all-artifact-build.cjs",
"files": [
"src/**/*",
"samples/**/*",
@@ -60,10 +59,6 @@
{
"from": "../COMMERCIAL_USE.md",
"to": "COMMERCIAL_USE.md"
},
{
"from": "build/dmg-background-ai-v4.tiff",
"to": "dmg-background.tiff"
}
],
"mac": {
@@ -75,23 +70,21 @@
]
},
"dmg": {
"background": "build/dmg-background-ai-v4.png",
"icon": null,
"writeUpdateInfo": false,
"background": "build/dmg-background-ai-v2.png",
"window": {
"width": 640,
"height": 420
"width": 540,
"height": 380
},
"iconSize": 112,
"iconSize": 128,
"contents": [
{
"x": 110,
"y": 205,
"x": 145,
"y": 210,
"type": "file"
},
{
"x": 520,
"y": 205,
"x": 395,
"y": 210,
"type": "link",
"path": "/Applications"
}
+1 -1
View File
@@ -1 +1 @@
{"manifest":{"connectorId":"external.local-echo-sample","connectorVersion":"1.0.0","credentials":{"mode":"none","slots":[]},"description":"Local SDK sample that returns text without delegating to a built-in Agent.","domains":["general"],"eventProtocolVersion":1,"eventTypes":["Completed","Failed","Cancelled"],"inputTypes":["text"],"invocation":{"recipeId":"sdk.local-echo.v1"},"kind":"agent","label":"Local Echo Connector Sample","license":"Apache-2.0","manifestId":"connector-manifest-6ce79bf9cbe282e9f537a1b45d742cc507e21ade5ea2859dff9e007bd4f850db","outboundDestinations":[],"permissionModes":["read-only"],"recordType":"agent-connector-manifest","schemaVersion":1,"session":{"cancel":true,"checkpoint":false,"resume":true,"supported":true},"transport":{"protocol":"json","type":"cli"},"upstream":{"id":"meldwork-sdk","maxVersion":"1.0.0","minVersion":"1.0.0"},"usage":{"costMicros":false,"elapsedMs":false,"inputTokens":false,"outboundBytes":false,"outputTokens":false,"toolCalls":false}},"packageId":"connector-package-f4908cee8fa7acf5d17a403b39bd58cb8d4a1dfec3fbfb43c62c5727ee793cd4","provider":{"config":{},"id":"sdk.local-echo.v1"},"publisher":{"id":"meldwork","name":"Meldwork"},"recordType":"agent-connector-package","schemaVersion":1}
{"manifest":{"connectorId":"external.local-echo-sample","connectorVersion":"1.0.0","credentials":{"mode":"none","slots":[]},"description":"Local SDK sample that returns text without delegating to a built-in Agent.","domains":["general"],"eventProtocolVersion":1,"eventTypes":["Completed","Failed","Cancelled"],"inputTypes":["text"],"invocation":{"recipeId":"sdk.local-echo.v1"},"kind":"agent","label":"Local Echo Connector Sample","license":"LicenseRef-Meldwork-NC","manifestId":"connector-manifest-6ce79bf9cbe282e9f537a1b45d742cc507e21ade5ea2859dff9e007bd4f850db","outboundDestinations":[],"permissionModes":["read-only"],"recordType":"agent-connector-manifest","schemaVersion":1,"session":{"cancel":true,"checkpoint":false,"resume":true,"supported":true},"transport":{"protocol":"json","type":"cli"},"upstream":{"id":"meldwork-sdk","maxVersion":"1.0.0","minVersion":"1.0.0"},"usage":{"costMicros":false,"elapsedMs":false,"inputTokens":false,"outboundBytes":false,"outputTokens":false,"toolCalls":false}},"packageId":"connector-package-f4908cee8fa7acf5d17a403b39bd58cb8d4a1dfec3fbfb43c62c5727ee793cd4","provider":{"config":{},"id":"sdk.local-echo.v1"},"publisher":{"id":"meldwork","name":"Meldwork"},"recordType":"agent-connector-package","schemaVersion":1}
@@ -1,126 +0,0 @@
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { execFileSync } = require('node:child_process')
const ROOT_HIDDEN_RESOURCES = ['.background.tiff', '.VolumeIcon.icns']
function selectDmgArtifacts(artifactPaths) {
return artifactPaths.filter(artifactPath => artifactPath.endsWith('.dmg'))
}
function findBundledPython(cacheRoot = process.env.ELECTRON_BUILDER_CACHE) {
const root = cacheRoot || path.join(os.homedir(), 'Library', 'Caches', 'electron-builder')
const pending = [root]
while (pending.length > 0) {
const current = pending.pop()
let entries
try {
entries = fs.readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const candidate = path.join(current, entry.name)
if (entry.isDirectory()) {
pending.push(candidate)
} else if (
entry.isFile()
&& /^python3(?:\.\d+)?$/.test(entry.name)
&& path.basename(current) === 'bin'
&& candidate.includes(`${path.sep}dmg-builder@`)
) {
return candidate
}
}
}
throw new Error(`Unable to locate dmg-builder's bundled Python under ${root}`)
}
function parseAttachedVolume(plist, workDir, run = execFileSync) {
const plistPath = path.join(workDir, 'attach.plist')
fs.writeFileSync(plistPath, plist)
const parsed = JSON.parse(run('plutil', ['-convert', 'json', '-o', '-', plistPath], {
encoding: 'utf8',
}))
const mounted = parsed['system-entities'].find(entity => entity['mount-point'])
if (!mounted) {
throw new Error('hdiutil did not return a mounted DMG volume')
}
return { device: mounted['dev-entry'], mountPath: mounted['mount-point'] }
}
function repointDmgBackground(dmgPath, options = {}) {
const run = options.execFile || execFileSync
const pythonPath = options.pythonPath || findBundledPython(options.cacheRoot)
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-dmg-'))
const readWriteDmg = path.join(workDir, 'writable.dmg')
const repackedDmg = `${dmgPath}.repacked-${process.pid}.dmg`
let device
try {
run('hdiutil', ['convert', dmgPath, '-format', 'UDRW', '-o', readWriteDmg], {
stdio: 'pipe',
})
const attached = parseAttachedVolume(run('hdiutil', [
'attach', readWriteDmg, '-nobrowse', '-readwrite', '-plist',
], { encoding: 'utf8' }), workDir, run)
device = attached.device
const backgroundPath = path.join(
attached.mountPath,
'Meldwork.app',
'Contents',
'Resources',
'dmg-background.tiff',
)
if (!fs.existsSync(backgroundPath)) {
throw new Error(`Packaged DMG background is missing: ${backgroundPath}`)
}
run(pythonPath, [
path.join(__dirname, 'repoint-dmg-background.py'),
path.join(attached.mountPath, '.DS_Store'),
backgroundPath,
], { stdio: 'pipe' })
for (const resource of ROOT_HIDDEN_RESOURCES) {
fs.rmSync(path.join(attached.mountPath, resource), { force: true })
}
run('/usr/bin/SetFile', ['-a', 'c', attached.mountPath], { stdio: 'pipe' })
run('/bin/sync', [], { stdio: 'pipe' })
run('hdiutil', ['detach', device], { stdio: 'pipe' })
device = null
run('hdiutil', [
'convert', readWriteDmg, '-format', 'UDZO', '-imagekey', 'zlib-level=9', '-o', repackedDmg,
], { stdio: 'pipe' })
fs.renameSync(repackedDmg, dmgPath)
} finally {
if (device) {
try {
run('hdiutil', ['detach', '-force', device], { stdio: 'pipe' })
} catch {}
}
fs.rmSync(repackedDmg, { force: true })
fs.rmSync(workDir, { recursive: true, force: true })
}
}
async function afterAllArtifactBuild(buildResult) {
for (const dmgPath of selectDmgArtifacts(buildResult.artifactPaths)) {
repointDmgBackground(dmgPath)
}
return []
}
afterAllArtifactBuild.ROOT_HIDDEN_RESOURCES = ROOT_HIDDEN_RESOURCES
afterAllArtifactBuild.selectDmgArtifacts = selectDmgArtifacts
afterAllArtifactBuild.findBundledPython = findBundledPython
afterAllArtifactBuild.parseAttachedVolume = parseAttachedVolume
afterAllArtifactBuild.repointDmgBackground = repointDmgBackground
module.exports = afterAllArtifactBuild
@@ -1,72 +0,0 @@
#!/usr/bin/env swift
import AppKit
import Foundation
let arguments = CommandLine.arguments
guard arguments.count == 3 else {
fputs("Usage: render-dmg-background.swift <source.png> <output.png>\n", stderr)
exit(1)
}
let sourceURL = URL(fileURLWithPath: arguments[1])
let outputURL = URL(fileURLWithPath: arguments[2])
guard let source = NSImage(contentsOf: sourceURL) else {
fputs("Unable to read source image\n", stderr)
exit(1)
}
guard let bitmap = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: 1280,
pixelsHigh: 840,
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0
) else {
fputs("Unable to create output bitmap\n", stderr)
exit(1)
}
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap)
source.draw(
in: NSRect(x: 0, y: 0, width: 1280, height: 840),
from: NSRect(x: 0, y: 4, width: 1280, height: 840),
operation: .copy,
fraction: 1
)
let centered = NSMutableParagraphStyle()
centered.alignment = .center
let title = "INSTALL MELDWORK" as NSString
title.draw(
in: NSRect(x: 0, y: 712, width: 1280, height: 52),
withAttributes: [
.font: NSFont(name: "DIN Alternate Bold", size: 38) ?? NSFont.boldSystemFont(ofSize: 38),
.foregroundColor: NSColor(white: 0.96, alpha: 0.94),
.kern: 4.5,
.paragraphStyle: centered,
]
)
let explanation = "DRAG MELDWORK INTO APPLICATIONS / YOUR WORKSPACE STAYS LOCAL" as NSString
explanation.draw(
in: NSRect(x: 0, y: 675, width: 1280, height: 30),
withAttributes: [
.font: NSFont(name: "Menlo-Regular", size: 14) ?? NSFont.monospacedSystemFont(ofSize: 14, weight: .regular),
.foregroundColor: NSColor(white: 0.82, alpha: 0.74),
.kern: 1.5,
.paragraphStyle: centered,
]
)
NSGraphicsContext.restoreGraphicsState()
guard let png = bitmap.representation(using: .png, properties: [:]) else {
fputs("Unable to encode PNG\n", stderr)
exit(1)
}
try png.write(to: outputURL)
-17
View File
@@ -1,17 +0,0 @@
#!/usr/bin/env python3
import sys
from ds_store import DSStore
from mac_alias import Alias
def main():
ds_store_path, background_path = sys.argv[1:3]
with DSStore.open(ds_store_path, "r+") as store:
icon_view = store["."]["icvp"]
icon_view["backgroundImageAlias"] = Alias.for_file(background_path).to_bytes()
store["."]["icvp"] = icon_view
if __name__ == "__main__":
main()
+1 -1
View File
@@ -5,7 +5,7 @@ const TEST_SUITES = Object.freeze({
agents: ['agents'],
attachments: ['attachments'],
collaboration: ['collaboration'],
core: ['channels', 'knowledge', 'media', 'skills'],
core: ['channels', 'knowledge', 'media', 'providers', 'skills'],
runs: ['runs'],
security: ['security', 'shell'],
workspace: ['workspace', 'gates'],
+2 -12
View File
@@ -5,7 +5,6 @@ const AGENT_VERSION_IDENTITIES = Object.freeze({
hermes: [/\bhermes(?:\s+agent)?\b/i],
openclaw: [/\bopenclaw\b/i],
workbuddy: [/\b(?:workbuddy|codebuddy)\b/i],
zcode: [/\bzcode\b/i],
pi: [/\bpi(?:\s+agent)?\b/i],
kimi: [/\bkimi(?:\s+code)?\b/i],
mimo: [/\bmimo(?:\s+code)?\b/i, /\bmimocode\b/i],
@@ -20,7 +19,6 @@ const AGENT_EXECUTABLE_NAMES = Object.freeze({
hermes: ['hermes'],
openclaw: ['openclaw'],
workbuddy: ['codebuddy'],
zcode: ['zcode'],
pi: ['pi', 'pi-agent', 'piagent'],
kimi: ['kimi'],
mimo: ['mimo'],
@@ -58,13 +56,8 @@ const AGENT_COMPATIBILITY = Object.freeze({
'json', 'plan', 'acceptEdits',
]),
]),
zcode: exactProfile('0.16.1', [
probe('zcode-prompt', ['--help'], ['--prompt', '--json', '--mode', '--resume', 'plan', 'edit']),
]),
pi: exactProfile('0.84.2', [
probe('pi-cli', ['--help'], [
'--mode', '--print', '--session', '--tools', '--approve', '--no-approve', 'json',
]),
probe('pi-cli', ['--help'], []),
]),
kimi: profile('0.19.2', '0.32.0', [
probe('kimi-stream', ['--help'], [
@@ -106,13 +99,11 @@ const AGENT_COMPATIBILITY = Object.freeze({
]),
})
function probe(id, args, requiredText, validateOutput = null, timeout = null) {
function probe(id, args, requiredText) {
return Object.freeze({
id,
args: Object.freeze([...args]),
requiredText: Object.freeze([...requiredText]),
validateOutput,
timeout,
})
}
@@ -270,7 +261,6 @@ module.exports = {
AGENT_COMPATIBILITY,
assessAgentVersion,
capabilityProbes,
compareReleaseVersions,
extractAgentVersion,
identifyAgentVersion,
}
+1 -25
View File
@@ -426,7 +426,7 @@ function importAgentOutputs(input, attachmentStore) {
&& !Array.isArray(input.baseline.files)
? input.baseline.files
: {}
const controlledCandidates = outputFiles(safeOutputDirectory(workdirRealPath))
const candidates = outputFiles(safeOutputDirectory(workdirRealPath))
.filter(file => !sameFileState(baselineFiles[file.name], file))
.filter(file => Math.max(file.mtimeMs, file.ctimeMs) >= startedAt - 2000)
.sort((left, right) => (
@@ -434,30 +434,6 @@ function importAgentOutputs(input, attachmentStore) {
|| left.name.localeCompare(right.name)
))
const reportedCandidates = []
const reportedPaths = Array.isArray(input.reportedPaths) ? input.reportedPaths : []
for (const reportedPath of reportedPaths.slice(0, MAX_SCANNED_ENTRIES)) {
if (typeof reportedPath !== 'string' || !path.isAbsolute(reportedPath)) continue
try {
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)
|| 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,
})
} catch { /* reported paths are best effort */ }
}
const candidates = [...controlledCandidates, ...reportedCandidates]
.filter((file, index, all) => all.findIndex(candidate => candidate.path === file.path) === index)
const imported = []
if (input.signal?.aborted) return imported
for (const file of candidates) {
@@ -1,85 +0,0 @@
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'])
function terminalPlanRejection(options = []) {
// Kimi's first reject option is "Revise", which loops without feedback.
return options.find(option => option?.optionId === 'plan_reject_and_exit'
&& ['reject_once', 'reject_always'].includes(option.kind))
}
// 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 = terminalPlanRejection(options)
|| 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 = {
terminalPlanRejection,
READ_ONLY_SAFE_TOOL_KINDS,
assertResumedPermissionBinding,
automaticPermissionResponse,
matchesResumedPermission,
permissionContinuationRequestId,
}
+4 -39
View File
@@ -1,4 +1,3 @@
const { authFailureCertainty } = require('../runs/failure-signals.cjs')
const AGENT_OUTCOMES = Object.freeze([
'accepted',
'running',
@@ -80,10 +79,6 @@ const AGENT_RUNTIME_CAPABILITIES = Object.freeze({
domains: ['general', 'software-development', 'document-production'],
toolClasses: ['filesystem', 'shell'],
}),
zcode: generalProfile({
domains: ['general', 'software-development', 'research'],
toolClasses: ['filesystem', 'shell'],
}),
pi: generalProfile({
domains: ['general', 'software-development'],
toolClasses: ['filesystem', 'shell'],
@@ -218,31 +213,16 @@ function normalizeExternalRunRef(value) {
function terminalAuthenticationDiagnostic(value) {
const text = String(value || '').replace(/\s+/g, ' ').trim()
if (!text || text.length > 2000) return ''
return /^HTTP\s+(?:401|403)\s*:\s*(?:unauthorized|forbidden|(?:invalid|expired|missing)\s+(?:access\s+token|token|credentials?|api[ _-]?key|authentication))(?:\s*\(request[ _-]?id:\s*[^()]{1,160}\))?\.?$/i
return /^HTTP\s+(?:401|403)\s*:\s*(?:(?:unauthorized|forbidden)\b|(?:invalid|expired|missing)\b[\s\S]*(?:token|credential|api[ _-]?key|auth))/i
.test(text)
? text
: ''
}
// 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' || value === 'LOCAL_WORKBUDDY_PROVIDER_AUTH') return 'authentication'
if (value === 'LOCAL_AGENT_ACCESS_DENIED') return 'provider'
if (value === 'LOCAL_AGENT_AUTH_REQUIRED') return 'authentication'
if (value === 'LOCAL_AGENT_TIMEOUT') return 'timeout'
if (value === 'LOCAL_AGENT_EXECUTION_STOPPED') return 'cancellation'
if (value === 'LOCAL_AGENT_REFUSED') return 'refusal'
@@ -319,19 +299,9 @@ function requireTerminalAgentResult(result) {
throw agentRuntimeError('LOCAL_AGENT_EXECUTION_STOPPED')
}
if (normalized.outcome === 'failed') {
let failure = normalized.failure?.code
const 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,
@@ -340,10 +310,7 @@ function requireTerminalAgentResult(result) {
}
const authenticationDiagnostic = terminalAuthenticationDiagnostic(normalized.text)
if (authenticationDiagnostic) {
throw agentRuntimeError(
authFailureCode(authenticationDiagnostic) || 'LOCAL_AGENT_ACCESS_DENIED',
authenticationDiagnostic,
)
throw agentRuntimeError('LOCAL_AGENT_AUTH_REQUIRED', authenticationDiagnostic)
}
return normalized
}
@@ -371,8 +338,6 @@ module.exports = {
agentRuntimeCapabilities,
agentRuntimeError,
agentRuntimeFailure,
authConfigurationFailure,
authFailureCode,
isCodeReviewAgentKind,
isReviewOnlyAgentKind,
normalizeAgentResult,
+18 -227
View File
@@ -1,7 +1,5 @@
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,31 +21,10 @@ const {
} = require('./cli-process-support.cjs')
const { createAcpOutboundPayload } = require('../../collaboration/outbound-payload.cjs')
const { redactSecrets } = require('../../security/secret-redaction.cjs')
const { createOutputRepetitionGuard } = require('./cli-output-repetition.cjs')
const { createTurnCompletion } = require('./cli-turn-completion.cjs')
const { terminalPlanRejection } = require('../agent-permission-policy.cjs')
const ACP_CANCEL_GRACE_MS = 250
// 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
// MiMo can flush the last delta of a message just after its prompt response.
const MIMO_TERMINAL_DRAIN_MS = 50
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_LINE_BYTES = 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
@@ -111,10 +88,6 @@ 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') {
@@ -126,8 +99,7 @@ function validateAcpInboundMessage(message, validators, replyState, protocolLabe
if (message.method === 'session/update' && !hasId) {
const parsed = validators.zSessionNotification.safeParse(message.params)
if (!parsed.success) {
replyState.ignoredMessages = (replyState.ignoredMessages || 0) + 1
return null
throw acpTransportError(`${protocolLabel} returned invalid session update parameters.`)
}
const update = parsed.data.update
if (replyState.collecting !== false
@@ -147,23 +119,7 @@ function validateAcpInboundMessage(message, validators, replyState, protocolLabe
}
return message
}
// 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
throw acpTransportError(`${protocolLabel} requested an unsupported client method.`)
}
if (!Number.isInteger(message.id) || message.id < 0) {
@@ -259,12 +215,6 @@ 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)
@@ -274,7 +224,6 @@ 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) {
@@ -360,7 +309,7 @@ function boundedAcpStream(
}
function permissionRejection(options) {
return terminalPlanRejection(options || []) || (options || []).find(option => (
return (options || []).find(option => (
['reject_once', 'reject_always'].includes(option.kind)
|| /reject|deny/i.test(`${option.optionId || ''} ${option.name || ''}`)
))
@@ -580,32 +529,6 @@ 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)
@@ -617,19 +540,14 @@ function withAcpRuntimeLock(key, callback) {
}
function runtimeSignature(agent, workdir, spec, profile, childEnv) {
const environment = Object.keys(childEnv).sort()
.filter(key => !ACP_VOLATILE_ENV_KEYS.has(key))
.map(key => [key, childEnv[key]])
const environment = Object.keys(childEnv).sort().map(key => [key, childEnv[key]])
return createHash('sha256').update(JSON.stringify({
kind: agent.kind,
executable: agent.executable,
workdir,
command: spec.command,
args: spec.args,
// The native session mode 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.
acpMode: spec.acpMode || '',
profileId: profile.profileId,
environment,
})).digest('hex')
@@ -735,14 +653,8 @@ 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
while (runtime.stderrBytes > ACP_STDERR_TAIL_BYTES && runtime.stderr.length > 1) {
runtime.stderrBytes -= runtime.stderr.shift().length
}
if (runtime.stderrBytes <= 1024 * 1024) runtime.stderr.push(chunk)
})
runtime.observedStdout = createObservedAcpInput(
child.stdout, runtime.replyState, protocolLabel,
@@ -751,11 +663,6 @@ async function createAcpRuntime(input) {
const client = {
async requestPermission(params) {
const turn = runtime.activeTurn
if (turn?.completion?.completed) return { outcome: { outcome: 'cancelled' } }
if (runtime.agent.kind === 'mimo' && runtime.replyState.promptTerminalSequence != null) {
runtime.replyState.permissionPendingAtPromptTerminal = true
return { outcome: { outcome: 'cancelled' } }
}
const pending = permissionOutcome(params, turn?.options || {}, childEnv)
turn?.pendingPermissionCallbacks.add(pending)
pending.then(
@@ -769,35 +676,11 @@ async function createAcpRuntime(input) {
try { turn?.noteActivity?.() } catch { /* activity is best-effort */ }
const sequence = runtime.replyState.pendingUpdateSequences.shift()
if (!turn || !runtime.replyState.collecting) return
if (params.sessionId !== runtime.protocolSessionRef) return
if (runtime.replyState.promptTerminalSequence != null
&& Number.isInteger(sequence)
&& sequence > runtime.replyState.promptTerminalSequence
&& !(runtime.agent.kind === 'mimo'
&& params.update?.sessionUpdate === 'agent_message_chunk'
&& turn.lastMessageId
&& params.update.messageId === turn.lastMessageId)) return
if (params.update?.sessionUpdate === 'agent_message_chunk' && params.update.messageId) {
turn.lastMessageId = params.update.messageId
}
let update = params.update
if (['tool_call', 'tool_call_update'].includes(update?.sessionUpdate)) {
if (['completed', 'failed'].includes(update.status)) turn.openTools.delete(update.toolCallId)
else if (update.status || update.sessionUpdate === 'tool_call') turn.openTools.add(update.toolCallId)
}
if (turn.completion && update?.sessionUpdate === 'agent_message_chunk'
&& update.content?.type === 'text') {
const next = turn.completion.push(update.content.text,
turn.openTools.size === 0 && turn.pendingPermissionCallbacks.size === 0)
update = { ...update, content: { ...update.content, text: next.delta } }
if (next.completed) turn.finishContribution()
}
if (turn.repetitionGuard?.(update)) {
turn.stopRepeatedOutput()
return
}
turn.structuredOutput.ingest?.({ ...params, update })
for (const event of turn.profile.mapEvent(update, turn.runtimeState)) {
&& sequence > runtime.replyState.promptTerminalSequence) return
turn.structuredOutput.ingest?.(params)
for (const event of turn.profile.mapEvent(params.update, turn.runtimeState)) {
turn.runtimeEvents.emit(event)
}
},
@@ -830,42 +713,15 @@ 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 {
// 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([
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
@@ -897,8 +753,6 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
try { options.onActivity?.() } catch { /* activity is best-effort */ }
}
const runtimeEvents = createRuntimeEventEmitter({ ...options, onActivity: noteActivity }, runtime.childEnv)
const completion = options.turnCompletion === true ? createTurnCompletion() : null
if (completion) prompt = `${prompt}\n\n${completion.instruction}`
const capabilityWarning = connectorLimitedRuntimeEvent(runtime.agent.kind, profile)
if (capabilityWarning) runtimeEvents.emit(capabilityWarning)
const turn = {
@@ -913,13 +767,8 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
noteActivity,
outboundPayloadFailed: false,
pendingPermissionCallbacks: new Set(),
completion,
openTools: new Set(),
repetitionGuard: ['opencode', 'mimo', 'hermes'].includes(runtime.agent.kind) ? createOutputRepetitionGuard() : null,
}
runtime.activeTurn = turn
runtime.stderr = []
runtime.stderrBytes = 0
runtime.replyState.bytes = 0
runtime.replyState.inputBytes = 0
runtime.replyState.observedInputBytes = 0
@@ -936,16 +785,12 @@ 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()
let rejectAbort
let resolveContribution
const contribution = new Promise(resolve => { resolveContribution = resolve })
const abortPromise = new Promise((_, reject) => { rejectAbort = reject })
const abort = (error = agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')) => {
const abort = () => {
if (abortRequested) return
abortRequested = true
if (protocolSessionRef) {
@@ -955,16 +800,7 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
})).catch(() => {})
} catch { /* the ACP stream has already closed */ }
}
rejectAbort(error instanceof Error ? error : agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED'))
}
turn.stopRepeatedOutput = () => {
runtime.replyState.collecting = false
abort(agentExecutionError('LOCAL_AGENT_REPEATED_OUTPUT',
'The Agent repeated the same response without tool progress; the native turn was cancelled.'))
}
turn.finishContribution = () => {
runtime.replyState.collecting = false
resolveContribution({ stopReason: 'end_turn' })
rejectAbort(agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED'))
}
const operation = promise => Promise.race([promise, abortPromise, runtime.failurePromise])
const timeout = setTimeout(() => {
@@ -1008,28 +844,14 @@ 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
promptRequest = Promise.resolve(runtime.connection.prompt({
promptResult = await operation(runtime.connection.prompt({
sessionId: protocolSessionRef,
prompt: [{ type: 'text', text: prompt }, ...imageBlocks],
prompt: [{ type: 'text', text: prompt }],
}))
promptRequest.catch(() => {})
promptResult = await operation(Promise.race([promptRequest, contribution]))
if (completion?.completed) {
await operation(settleWithin(Promise.resolve(runtime.connection.cancel({ sessionId: protocolSessionRef }))
.catch(() => {}), ACP_CANCEL_GRACE_MS))
let settled = false
await operation(settleWithin(promptRequest.then(() => { settled = true }, () => { settled = true }),
ACP_CANCEL_SETTLE_MS))
if (!settled) runtime.contributionNeedsClose = true
}
if (runtime.agent.kind === 'mimo' && turn.lastMessageId) {
await operation(new Promise(resolve => setTimeout(resolve, MIMO_TERMINAL_DRAIN_MS)))
}
} finally {
runtime.replyState.collecting = false
}
@@ -1064,15 +886,9 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
? 'timeout'
: normalized.failure?.category === 'cancellation' ? 'stopped' : 'failed',
})
if (promptStarted && options.sessionRef
&& runtime.replyState.bytes === 0
&& normalized.message === 'LOCAL_AGENT_REFUSED') {
throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
}
if (!promptStarted && normalized.message !== 'LOCAL_AGENT_SESSION_INVALID') {
allowAcpSetupFallback(normalized)
}
caughtError = normalized
throw normalized
} finally {
clearTimeout(timeout)
@@ -1085,26 +901,7 @@ 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)
// 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 && caughtError.code !== 'LOCAL_AGENT_REPEATED_OUTPUT'
&& promptSettled && !runtime.closed && !runtime.failureError) {
Object.defineProperty(caughtError, 'acpRuntimeReusable', {
value: true, enumerable: false, configurable: true,
})
}
}
if (abortRequested) await settleWithin(cancelPromise, ACP_CANCEL_GRACE_MS)
}
}
@@ -1168,17 +965,11 @@ async function runPersistentAcpAgent(agent, prompt, workdir, options, spec, prof
try {
return await runAcpTurn(runtime, prompt, options, spec, profile)
} catch (error) {
if (error?.acpRuntimeReusable !== true) {
acpSessionRuntimes.delete(key)
await closeAcpRuntime(runtime)
}
acpSessionRuntimes.delete(key)
await closeAcpRuntime(runtime)
throw error
} finally {
runtime.active = false
if (runtime.contributionNeedsClose) {
acpSessionRuntimes.delete(key)
await closeAcpRuntime(runtime)
}
runtime.lastUsedAt = Date.now()
await evictIdleAcpRuntimes()
}
+7 -59
View File
@@ -15,7 +15,6 @@ const {
parseMimoOutput,
parseOpenCodeOutput,
parseOpenCodeReviewOutput,
parsePiOutput,
parseWorkBuddyOutput,
readHermesFinalResponse,
readHermesMessageWatermark,
@@ -30,7 +29,6 @@ const {
shutdownAcpSessionRuntime,
} = require('./cli-acp-runner.cjs')
const { withOpenClawGateway } = require('./cli-openclaw-gateway.cjs')
const { createOutputRepetitionGuard } = require('./cli-output-repetition.cjs')
const { imageAttachmentLimit, invocation } = require('./cli-invocations.cjs')
const {
connectorLimitedRuntimeEvent,
@@ -48,24 +46,12 @@ const {
failedAgentProcessError,
} = require('./cli-process-support.cjs')
const { createLegacyOutboundPayload } = require('../../collaboration/outbound-payload.cjs')
const { authFailureCertainty } = require('../../runs/failure-signals.cjs')
const MAX_PROGRESS_STEPS = 8
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
@@ -136,15 +122,13 @@ async function runAgent(agent, prompt, workdir, options = {}) {
? options.hermesAcpAvailable
: agent.acpAvailable
const spec = invocation(agent.kind, agent.executable, workdir, sessionRef, {
platform,
sandbox: options.sandbox,
provider: options.provider,
attachments: options.attachments,
hermesAcpAvailable,
hermesStreamJsonAvailable: agent.streamJsonAvailable,
invocationTransport: options.invocationTransport,
sessionTransport: options.sessionTransport,
maxTurns: options.maxTurns,
model: agent.kind === 'workbuddy' ? options.env?.CODEBUDDY_MODEL : undefined,
})
const profile = resolveConnectorEventProfile(agent.kind, {
transport: spec.eventTransport,
@@ -228,8 +212,6 @@ async function runAgent(agent, prompt, workdir, options = {}) {
}))
}
const childEnv = childEnvironment(agent, workdir, options, platform)
const repetitionGuard = ['opencode', 'mimo', 'hermes'].includes(agent.kind)
? createOutputRepetitionGuard() : null
const runtimeEvents = createRuntimeEventEmitter({ ...options, onActivity: noteActivity }, childEnv)
const capabilityWarning = connectorLimitedRuntimeEvent(agent.kind, profile)
if (capabilityWarning) runtimeEvents.emit(capabilityWarning)
@@ -262,7 +244,6 @@ 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
@@ -300,12 +281,6 @@ async function runAgent(agent, prompt, workdir, options = {}) {
const progressEvent = profile.mapProgress(event)
if (progressEvent) emitProgress(progressEvent)
for (const runtimeEvent of profile.mapEvent(event, runtimeState)) {
if (stopRequested || settled) return
if (repetitionGuard?.(runtimeEvent)) {
inputError = agentExecutionError('LOCAL_AGENT_REPEATED_OUTPUT')
abort()
return
}
runtimeEvents.emit(runtimeEvent)
}
})
@@ -318,7 +293,6 @@ async function runAgent(agent, prompt, workdir, options = {}) {
clearTimeout(forceSettleTimeout)
clearTimeout(terminalKillTimer)
clearTimeout(terminalForceKillTimer)
clearTimeout(brokenInputTimer)
options.signal?.removeEventListener('abort', abort)
callback()
}
@@ -338,13 +312,9 @@ async function runAgent(agent, prompt, workdir, options = {}) {
if (terminalObserved || !event || typeof event !== 'object') return
const terminal = (
(['workbuddy', 'claude', 'qwen'].includes(agent.kind) && event.type === 'result')
|| (agent.kind === 'hermes' && spec.eventTransport === 'stream-json' && event.type === 'result')
|| (agent.kind === 'pi' && (event.type === 'agent_settled'
|| (event.type === 'agent_end'
&& !Object.hasOwn(event, 'willRetry') && !Object.hasOwn(event, 'willContinue'))))
|| (agent.kind === 'pi' && ['turn_end', 'agent_end'].includes(event.type))
|| (agent.kind === 'codex' && event.type === 'turn.completed')
|| (['mimo', 'opencode'].includes(agent.kind) && event.type === 'step_finish'
&& !stepFinishContinues(event))
|| (['mimo', 'opencode'].includes(agent.kind) && event.type === 'step_finish')
|| (agent.kind === 'gemini' && event.type === 'result')
|| (agent.kind === 'kimi' && event.type === 'session.resume_hint')
)
@@ -413,23 +383,10 @@ async function runAgent(agent, prompt, workdir, options = {}) {
})
child.stdin.on('error', (error) => {
if (settled || inputError) return
const sendFailure = () => agentExecutionError(
inputError = 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(
@@ -463,12 +420,6 @@ async function runAgent(agent, prompt, workdir, options = {}) {
})
const rawStdout = stdout.text()
const structuredDetail = structuredCliError(rawStdout)
if (!String(result?.text || '').trim() && authFailureCertainty(rawStderr) === 'definitive') {
rejectTerminal(failedAgentProcessError(redactChildSecrets(rawStderr, childEnv), {
sessionRef: failureSessionRef, kind: agent.kind, model: options.env?.CODEBUDDY_MODEL,
}))
return
}
const usableNonZeroResult = code !== 0
&& result
&& typeof result.text === 'string'
@@ -476,18 +427,16 @@ async function runAgent(agent, prompt, workdir, options = {}) {
&& ['completed', 'partial'].includes(result.outcome)
if (code !== 0 && !usableNonZeroResult) {
const detail = redactChildSecrets(
[result?.error, rawStderr, structuredDetail].filter(Boolean).join('\n'),
[rawStderr, structuredDetail].filter(Boolean).join('\n'),
childEnv,
)
rejectTerminal(failedAgentProcessError(detail, {
sessionRef: failureSessionRef, kind: agent.kind, model: options.env?.CODEBUDDY_MODEL,
}))
rejectTerminal(failedAgentProcessError(detail, { sessionRef: failureSessionRef }))
return
}
if (result.error) {
rejectTerminal(failedAgentProcessError(
redactChildSecrets(result.error, childEnv),
{ sessionRef: failureSessionRef, kind: agent.kind, model: options.env?.CODEBUDDY_MODEL },
{ sessionRef: failureSessionRef },
))
return
}
@@ -551,7 +500,6 @@ module.exports = {
parseMimoOutput,
parseOpenCodeOutput,
parseOpenCodeReviewOutput,
parsePiOutput,
parseWorkBuddyOutput,
prepareCommand,
readHermesFinalResponse,
+26 -81
View File
@@ -3,7 +3,6 @@ const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { promisify } = require('node:util')
const { networkEnvironment } = require('./cli-network-environment.cjs')
const {
assessAgentVersion,
capabilityProbes,
@@ -16,7 +15,6 @@ const AGENT_PROFILES = {
hermes: { label: 'Hermes', commands: ['hermes'] },
openclaw: { label: 'OpenClaw', commands: ['openclaw'] },
workbuddy: { label: 'WorkBuddy', commands: ['codebuddy'] },
zcode: { label: 'Zcode', commands: ['zcode'] },
pi: { label: 'Pi Agent', commands: ['pi', 'pi-agent', 'piagent'] },
kimi: { label: 'Kimi', commands: ['kimi'] },
mimo: { label: 'MiMo', commands: ['mimo'] },
@@ -28,8 +26,6 @@ const AGENT_PROFILES = {
}
const ALLOWED_KINDS = Object.keys(AGENT_PROFILES)
const DEFAULT_WINDOWS_PATHEXT = ['.COM', '.EXE', '.BAT', '.CMD']
const CAPABILITY_CACHE_TTL_MS = 5 * 60 * 1000
const capabilityCache = new Map()
const SYSTEM_CHILD_ENV_KEYS = Object.freeze([
'HOME', 'USER', 'LOGNAME', 'SHELL',
'TMPDIR', 'TMP', 'TEMP',
@@ -42,7 +38,7 @@ const SYSTEM_CHILD_ENV_KEYS = Object.freeze([
'APPDATA', 'LOCALAPPDATA', 'PROGRAMDATA',
'PROGRAMFILES', 'PROGRAMFILES(X86)', 'PROGRAMW6432',
'OS', 'PROCESSOR_ARCHITECTURE', 'PROCESSOR_IDENTIFIER', 'NUMBER_OF_PROCESSORS',
'CODEX_HOME', 'CLAUDE_CONFIG_DIR', 'PI_CODING_AGENT_DIR',
'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS',
])
function envValue(env, name) {
@@ -51,7 +47,7 @@ function envValue(env, name) {
}
function systemChildEnvironment(sourceEnv = process.env, platform = process.platform) {
const env = networkEnvironment(sourceEnv, platform)
const env = {}
for (const name of SYSTEM_CHILD_ENV_KEYS) {
const value = envValue(sourceEnv, name)
if (typeof value === 'string' && value) env[name] = value
@@ -210,23 +206,6 @@ 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 ['']
@@ -351,57 +330,31 @@ 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),
}),
...launchEnvironment(kind, executable, options),
const childEnv = options.childEnv || {
...systemChildEnvironment(env, platform),
PATH: searchPath(options),
}
let identity = ''
try {
const stat = (options.statFn || fs.statSync)(executable)
identity = JSON.stringify([
kind, executable, options.version || '', stat.dev, stat.ino,
stat.size, stat.mtimeMs, stat.ctimeMs,
])
} catch { /* caching requires a verifiable executable identity */ }
const cacheKey = `${kind}:${executable}`
const prior = capabilityCache.get(cacheKey)
const recentCompatible = identity && prior?.identity === identity
&& Date.now() - prior.checkedAt < CAPABILITY_CACHE_TTL_MS
const results = await Promise.all(capabilityProbes(kind).map(async (probe) => {
for (const timeout of probe.timeout ? [probe.timeout] : [8000, 16000]) {
try {
const command = prepareCommandFn(executable, probe.args, options)
const result = await execFileFn(command.command, command.args, {
timeout,
windowsHide: true,
env: childEnv,
})
return { probe, output: `${result.stdout || ''}\n${result.stderr || ''}` }
} catch (error) {
const timedOut = error?.killed === true || error?.code === 'ETIMEDOUT'
if (timedOut && timeout === 8000) continue
return { probe, error: true, timedOut }
try {
const command = prepareCommandFn(executable, probe.args, options)
const result = await execFileFn(command.command, command.args, {
timeout: 8000,
windowsHide: true,
env: childEnv,
})
return {
probe,
output: `${result.stdout || ''}\n${result.stderr || ''}`,
}
} catch {
return {
probe,
error: true,
}
}
}))
const failed = results.find(result => !result.timedOut && (result.error
|| result.probe.requiredText.some(value => !result.output.includes(value))
|| (result.probe.validateOutput && !result.probe.validateOutput(result.output))))
if (failed) capabilityCache.delete(cacheKey)
for (const result of results) {
const { probe } = result
if (result.timedOut && !failed) {
if (recentCompatible) continue
return {
compatibilityState: 'unknown',
incompatibilityReason: 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT',
incompatibilityProbe: probe.id,
}
}
if (result.timedOut) continue
if (result.error) {
return {
compatibilityState: 'incompatible',
@@ -419,15 +372,10 @@ async function probeAgentCapabilities(kind, executable, options = {}) {
}
}
}
if (identity && !results.some(result => result.timedOut)) {
capabilityCache.set(cacheKey, { identity, checkedAt: Date.now() })
}
return {
compatibilityState: 'compatible',
incompatibilityReason: '',
incompatibilityProbe: '',
...(kind === 'hermes' && results.some(result => result.output?.includes('--format')
&& result.output.includes('stream-json')) ? { streamJsonAvailable: true } : {}),
}
}
@@ -454,17 +402,18 @@ async function inspectAgentCandidate(kind, executable, options, childEnv) {
version: identified?.line || '',
versionIdentified: Boolean(identified),
}
} catch { /* capability probes can still establish whether this installation works */ }
} catch { /* a broken shim is not a usable CLI */ }
return { succeeded: false, version: '', versionIdentified: false }
})()
const { version } = await versionTask
const { succeeded: versionCommandSucceeded, version } = await versionTask
if (!versionCommandSucceeded) return null
const versionCompatibility = assessAgentVersion(kind, version)
const capabilityTask = Promise.resolve().then(async () => {
const result = await (options.probeAgentCapabilitiesFn || probeAgentCapabilities)(
kind,
executable,
{ ...options, childEnv, version },
{ ...options, childEnv },
)
return result && typeof result === 'object' ? result : incompatibleCapabilities
}).catch(() => incompatibleCapabilities)
@@ -484,7 +433,7 @@ async function inspectAgentCandidate(kind, executable, options, childEnv) {
})()
: Promise.resolve(undefined)
const [capabilityCompatibility, acpAvailable] = await Promise.all([capabilityTask, acpTask])
const compatibility = capabilityCompatibility.compatibilityState !== 'compatible'
const compatibility = capabilityCompatibility.compatibilityState === 'incompatible'
? { ...versionCompatibility, ...capabilityCompatibility }
: {
...versionCompatibility,
@@ -498,10 +447,7 @@ async function inspectAgentCandidate(kind, executable, options, childEnv) {
executable,
version,
...compatibility,
...(kind === 'hermes' ? {
acpAvailable,
...(capabilityCompatibility.streamJsonAvailable ? { streamJsonAvailable: true } : {}),
} : {}),
...(kind === 'hermes' ? { acpAvailable } : {}),
}
}
@@ -539,7 +485,6 @@ module.exports = {
AGENT_PROFILES,
ALLOWED_KINDS,
detectAgents,
launchEnvironment,
prepareCommand,
probeAgentCapabilities,
resolveExecutable,
+1 -89
View File
@@ -450,99 +450,11 @@ const HERMES_FINAL_TEXT_PROFILE = profile({
createFinalOutputAccumulator: createHermesLegacyFinalOutputAccumulator,
})
const HERMES_STREAM_JSON_PROFILE = profile({
profileId: 'hermes-stream-json-v1',
protocol: 'hermes-stream-json',
framing: 'jsonl',
source: 'stdout',
capabilities: {
answerMode: 'delta', tools: START_RESULT_TOOL_LIFECYCLE,
plan: false, reasoning: false, session: true, terminal: true,
},
createDecoder: createJsonLineParser,
createState: createStatelessRuntimeState,
mapEvent(event) {
if (event.type === 'text' && typeof event.text === 'string') {
return [{ type: 'answer_delta', status: 'running', delta: event.text }]
}
if (['tool_use', 'tool_result'].includes(event.type)) {
return [{
id: event.tool_call_id || event.name,
type: event.type === 'tool_use' ? 'tool_start' : 'tool_result_summary',
title: event.name || 'tool',
status: event.type === 'tool_use' ? 'running' : event.is_error ? 'failed' : 'completed',
}]
}
return []
},
async finalizeResult({ result, options }) {
// Both native chat formats use the same resumable conversation IDs.
if (result.sessionRef) await options.onSessionRef?.(result.sessionRef, { transport: 'legacy' })
return result
},
createFinalOutputAccumulator(sessionRef = '') {
const state = { text: '', sessionRef, outcome: 'partial' }
const ingest = event => {
if (typeof event.session_id === 'string' && event.session_id) state.sessionRef = event.session_id
if (event.type === 'text' && typeof event.text === 'string') appendFinalText(state, event.text)
if (event.type === 'result') {
if (typeof event.text === 'string') state.text = event.text.slice(0, MAX_FINAL_TEXT_CHARS)
state.outcome = event.exit_code === 0 && !event.error ? 'completed' : 'failed'
if (event.error) state.error = String(event.error)
}
}
const parser = createJsonLineParser(ingest)
return {
ingest,
write: chunk => parser.write(chunk),
end() {
parser.end()
return { ...state }
},
}
},
})
const ZCODE_JSON_PROFILE = profile({
profileId: 'zcode-prompt-json-v1',
protocol: 'zcode-prompt-json',
framing: 'document',
source: 'stdout',
capabilities: {
answerMode: 'final', tools: NO_TOOL_LIFECYCLE,
plan: false, reasoning: false, session: true, terminal: true,
},
createState: createStatelessRuntimeState,
createDecoder: null,
mapEvent: finalOnlyRuntimeEvents,
createFinalOutputAccumulator() {
const decoder = new StringDecoder('utf8')
const raw = { text: '' }
return {
capture(chunk) { appendFinalText(raw, decoder.write(chunk)) },
end() {
appendFinalText(raw, decoder.end())
let value
try { value = JSON.parse(raw.text) } catch { return { text: '', outcome: 'failed' } }
if (typeof value.response !== 'string' || typeof value.sessionId !== 'string') {
return { text: '', outcome: 'failed' }
}
return {
text: value.response,
sessionRef: value.sessionId,
outcome: ['idle', 'completed'].includes(value.projection?.status) ? 'completed' : 'partial',
}
},
}
},
})
const CONNECTOR_EVENT_PROFILES = Object.freeze({
codex: CODEX_APP_SERVER_PROFILE,
hermes: ACP_JSONRPC_PROFILE,
openclaw: ACP_JSONRPC_PROFILE,
workbuddy: WORKBUDDY_TERMINAL_RESULT_PROFILE,
zcode: ZCODE_JSON_PROFILE,
pi: PI_JSON_EVENTS_PROFILE,
kimi: ACP_JSONRPC_PROFILE,
mimo: MIMO_JSON_EVENTS_PROFILE,
@@ -554,7 +466,7 @@ const CONNECTOR_EVENT_PROFILES = Object.freeze({
})
const TRANSPORT_EVENT_PROFILES = Object.freeze({
hermes: Object.freeze({ legacy: HERMES_FINAL_TEXT_PROFILE, 'stream-json': HERMES_STREAM_JSON_PROFILE }),
hermes: Object.freeze({ legacy: HERMES_FINAL_TEXT_PROFILE }),
openclaw: Object.freeze({ legacy: OPENCLAW_TERMINAL_DOCUMENT_PROFILE }),
kimi: Object.freeze({ 'stream-json': KIMI_STREAM_JSON_PROFILE }),
mimo: Object.freeze({ acp: ACP_JSONRPC_PROFILE }),
+29 -48
View File
@@ -7,13 +7,12 @@ const IMAGE_ATTACHMENT_LIMITS = Object.freeze({
opencode: 4,
})
const DEFAULT_INTERNAL_TURN_LIMITS = Object.freeze({
// Preserve the default for independent replies. Automatic discussions pass
// an explicit zero to let the native tool loop finish its work.
// Meldwork already owns the outer discussion rounds. Keep WorkBuddy's
// nested tool loop bounded so one participant cannot dominate a round.
workbuddy: 8,
})
function internalTurnLimit(kind, requested) {
if (requested === 0) return 0
const value = Number(requested)
if (Number.isInteger(value) && value >= 1 && value <= 50) return value
return DEFAULT_INTERNAL_TURN_LIMITS[kind]
@@ -74,24 +73,9 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
}
}
if (kind === 'hermes') {
if (options.hermesStreamJsonAvailable === true
&& (options.hermesAcpAvailable === false || options.invocationTransport === 'stream-json'
|| (sessionRef && options.sessionTransport !== 'acp'))
&& (!sessionRef || options.sessionTransport !== 'acp')) {
return {
command: executable,
args: ['chat', '--format', 'stream-json',
...(options.sandbox === 'workspace-write' ? ['--yolo'] : []),
...(sessionRef ? ['--resume', sessionRef] : []),
...(attachments[0] ? ['--image', attachments[0]] : []), '--query'],
promptArg: true,
eventTransport: 'stream-json',
}
}
// Hermes uses the same native conversation identifier across its ACP and
// legacy transports. Keep it when an attachment requires the legacy path.
const legacySessionRef = sessionRef
const legacySessionRef = options.sessionTransport === 'acp' ? '' : sessionRef
const useAcp = options.invocationTransport !== 'legacy'
&& !attachments.length
&& options.hermesAcpAvailable !== false
&& (!sessionRef || options.sessionTransport === 'acp')
if (useAcp) {
@@ -102,9 +86,6 @@ 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 {
@@ -113,6 +94,8 @@ 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',
@@ -160,35 +143,20 @@ 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',
...(maxTurns ? ['--max-turns', String(maxTurns)] : []),
...(options.model ? ['--model', options.model] : []),
...(sessionRef ? ['--resume', sessionRef] : []),
],
promptArg: true,
eventTransport: 'stream-json',
}
}
if (kind === 'zcode') {
return {
command: executable,
args: ['--json', '--no-color', '--verbose', '--cwd', workdir,
'--mode', options.sandbox === 'workspace-write' ? 'edit' : 'plan',
...(sessionRef ? ['--resume', sessionRef] : []), '--prompt'],
promptArg: true,
}
}
if (kind === 'pi') {
return {
command: executable,
args: [
'--mode', 'json', '--print',
...(options.sandbox === 'workspace-write'
? ['--approve']
: ['--no-approve', '--tools', 'read,grep,find,ls']),
...(options.sandbox === 'workspace-write' ? ['--approve'] : ['--no-approve']),
...(sessionRef ? ['--session', sessionRef] : []),
],
promptArg: true,
@@ -196,20 +164,31 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
}
}
if (kind === 'kimi') {
// Kimi rejects --auto together with --prompt. Use the permission-aware
// ACP transport for both modes instead of bypassing approval with --yolo.
if (options.sandbox !== 'workspace-write') {
return {
command: executable,
args: ['acp'],
acpMode: 'plan',
eventTransport: 'acp',
}
}
return {
command: executable,
args: ['acp'],
acpMode: 'default',
eventTransport: 'acp',
args: [
'--output-format', 'stream-json',
'--auto',
...(sessionRef ? ['--session', sessionRef] : []),
'--prompt',
],
promptArg: true,
eventTransport: 'stream-json',
}
}
if (kind === 'mimo') {
if (!attachments.length && options.invocationTransport !== 'json') {
if (options.invocationTransport !== 'json') {
return {
command: executable,
args: ['acp', '--cwd', workdir],
args: ['acp', '--pure', '--cwd', workdir],
acpMode: options.sandbox === 'workspace-write' ? 'build' : 'plan',
eventTransport: 'acp',
fallbackTransport: 'json',
@@ -219,7 +198,7 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
return {
command: executable,
args: [
'run', '--agent', options.sandbox === 'workspace-write' ? 'build' : 'plan',
'run', '--pure', '--agent', options.sandbox === 'workspace-write' ? 'build' : 'plan',
'--format', 'json', '--dir', workdir,
...(sessionRef ? ['--session', sessionRef] : []),
],
@@ -247,6 +226,8 @@ 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,
@@ -268,7 +249,7 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
if (!attachments.length && options.invocationTransport !== 'json') {
return {
command: executable,
args: ['acp', '--cwd', workdir],
args: ['acp', '--pure', '--cwd', workdir],
acpMode: options.sandbox === 'workspace-write' ? 'build' : 'plan',
eventTransport: 'acp',
fallbackTransport: 'json',
@@ -1,19 +0,0 @@
const NETWORK_ENV_KEYS = Object.freeze([
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy',
'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS',
'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', 'NODE_USE_ENV_PROXY',
])
function networkEnvironment(source = {}, platform = process.platform) {
const result = {}
for (const [key, value] of Object.entries(source)) {
const allowed = platform === 'win32'
? NETWORK_ENV_KEYS.find(name => name.toLowerCase() === key.toLowerCase())
: NETWORK_ENV_KEYS.find(name => name === key)
if (allowed && typeof value === 'string') result[allowed] = value
}
return result
}
module.exports = { NETWORK_ENV_KEYS, networkEnvironment }
@@ -7,10 +7,7 @@ const {
TERMINATE_GRACE_MS,
childEnvironment,
} = require('./cli-process-support.cjs')
const {
configureOpenClawGatewayRuntime,
refreshOpenClawRuntimeAfterStartup,
} = require('./openclaw-runtime.cjs')
const { configureOpenClawGatewayRuntime } = require('./openclaw-runtime.cjs')
const GATEWAY_READY_MARKER = '[gateway] ready'
const GATEWAY_READY_TIMEOUT_MS = 15_000
@@ -289,11 +286,8 @@ async function withOpenClawGateway(options, callback) {
signal: options.signal,
workdir: options.workdir,
})
runtimeOptions = refreshOpenClawRuntimeAfterStartup(runtimeOptions)
} catch (error) {
await terminateGateway(child)
// Startup can migrate the config before a port conflict or health failure.
runtimeOptions = refreshOpenClawRuntimeAfterStartup(runtimeOptions)
if (error?.retryable && attempt + 1 < GATEWAY_SETUP_ATTEMPTS) continue
throw attachRuntimeOptions(error, runtimeOptions)
}
+15 -55
View File
@@ -172,38 +172,17 @@ 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') {
&& event.type === 'result' && typeof event.result === 'string') {
if (typeof event.session_id === 'string') state.sessionRef = event.session_id
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))
}
state.failure = null
state.diagnostic = ''
state.text = ''
appendStructuredText(state, event.result, '')
state.outcome = event.is_error === true ? 'partial' : 'completed'
return
}
if (structuredEventFailed(event)) {
@@ -237,14 +216,13 @@ function ingestStructuredEvent(state, event) {
}
if (state.kind === 'pi') {
if (event.type === 'session' && typeof event.id === 'string') state.sessionRef = event.id
if (typeof event.id === 'string') state.sessionRef = event.id
const update = event.assistantMessageEvent
if (event.type === 'message_update' && update?.type === 'text_delta'
&& typeof update.delta === 'string') {
appendStructuredText(state, update.delta, '')
}
if (event.type === 'message_end' && event.message?.role === 'assistant'
&& Array.isArray(event.message?.content)) {
if (event.type === 'message_end' && Array.isArray(event.message?.content)) {
for (const block of event.message.content) {
if (block?.type === 'text' && typeof block.text === 'string') {
if (!state.text) appendStructuredText(state, block.text, '')
@@ -256,17 +234,7 @@ function ingestStructuredEvent(state, event) {
if (event.type === 'turn_end') {
state.outcome = stepFinishOutcome(event.message?.stopReason || event.stopReason || 'stop')
}
if (event.type === 'agent_end') {
state.piContinuing = event.willRetry === true || event.willContinue === true
const lastAssistant = event.messages?.findLast(message => message?.role === 'assistant')
if (lastAssistant?.stopReason) state.outcome = stepFinishOutcome(lastAssistant.stopReason)
else if (!state.outcome) state.outcome = 'completed'
if (state.outcome === 'completed' && !state.piContinuing) {
state.failure = null
state.diagnostic = ''
}
}
if (event.type === 'agent_settled') state.piContinuing = false
if (event.type === 'agent_end') state.outcome = 'completed'
if (state.outcome === 'failed') markStructuredFailure(state)
return
}
@@ -430,8 +398,7 @@ function finalizeStructuredState(state, context = {}) {
}
if (!state.seen && !state.text && !state.outcome) return null
const outcome = state.kind === 'pi' && state.piContinuing && state.outcome === 'completed'
? 'partial' : state.outcome || (state.text ? 'partial' : 'failed')
const outcome = state.outcome || (state.text ? 'partial' : 'failed')
const result = {
text: state.kind === 'pi'
? stripPiPromptEcho(state.text, context.prompt)
@@ -742,8 +709,7 @@ function parsePiOutput(stdout) {
&& typeof event.assistantMessageEvent.delta === 'string') {
text += event.assistantMessageEvent.delta
}
if (!text && event.type === 'message_end' && event.message?.role === 'assistant'
&& Array.isArray(event.message?.content)) {
if (!text && event.type === 'message_end' && Array.isArray(event.message?.content)) {
text = event.message.content
.filter(block => block?.type === 'text' && typeof block.text === 'string')
.map(block => block.text)
@@ -752,7 +718,7 @@ function parsePiOutput(stdout) {
}
const parsedText = text.trim()
const answer = stripPiInternalPromptEcho(parsedText)
return { text: parsedText ? answer : events.length ? '' : String(stdout || '').trim(), sessionRef }
return { text: parsedText ? answer : String(stdout || '').trim(), sessionRef }
}
function openCodeReviewText(output) {
@@ -867,13 +833,8 @@ function classifyCliOutcome(kind, stdout) {
}
if (kind === 'pi') {
const terminal = events.findLast(event => event?.type === 'turn_end' || event?.type === 'agent_end')
if ((terminal?.willRetry === true || terminal?.willContinue === true)
&& events.at(-1)?.type !== 'agent_settled') return { outcome: 'partial' }
const lastAssistant = terminal?.messages?.findLast(message => message?.role === 'assistant')
const previousTurn = events.findLast(event => event?.type === 'turn_end')
const reason = terminal?.message?.stopReason || terminal?.stopReason || lastAssistant?.stopReason
|| previousTurn?.message?.stopReason || previousTurn?.stopReason
return { outcome: reason ? stepFinishOutcome(reason) : terminal ? 'completed' : 'partial' }
const reason = terminal?.message?.stopReason || terminal?.stopReason || 'stop'
return { outcome: stepFinishOutcome(reason) }
}
if (['mimo', 'opencode'].includes(kind)) {
const finish = events.findLast(event => (
@@ -911,8 +872,7 @@ function classifyCliOutcome(kind, stdout) {
function structuredCliError(stdout) {
const failed = parseJsonOutputEvents(stdout)
.findLast(event => event?.is_error || event?.subtype?.startsWith('error'))
if (!failed) return ''
return structuredResultDiagnostic(failed)
return String(failed?.error?.message || failed?.result || '').trim()
}
function hermesSessionRef(stderr) {
@@ -1,43 +0,0 @@
// Detect a runaway native turn, rather than deduplicating its visible answer.
// Exact repetitions and sustained paraphrased closing acknowledgements count;
// real tool activity resets the window.
const CLOSING_ACKNOWLEDGEMENT = /^(?:(?:确认(?:收到)?|收到|本轮).{0,100}(?:收尾|结束|完成|定稿|后续轮次|按.{0,16}分工).{0,30}|确认|收到|确认收到|(?:confirmed|acknowledged|done)(?:[,:;].{0,100})?)$/iu
function createOutputRepetitionGuard() {
let text = ''
let unchecked = 0
return update => {
const type = update?.sessionUpdate || update?.type
if (['tool_call', 'tool_call_update', 'tool_start', 'tool_result_summary'].includes(type)) {
text = ''
unchecked = 0
return false
}
const value = type === 'answer_delta' ? update.delta
: type === 'agent_message_chunk' && update.content?.type === 'text' ? update.content.text : ''
const chunk = String(value || '').replace(/\s/g, '')
if (!chunk) return false
text = (text + chunk).slice(-8192)
unchecked += chunk.length
if (unchecked < 32 || text.length < 64) return false
unchecked = 0
const clauses = text.split(/[。!?.!?]+/u).filter(Boolean).slice(-12)
if (clauses.filter(clause => CLOSING_ACKNOWLEDGEMENT.test(clause)).length >= 8) return true
if (new Set(text).size < 6) return false
const anchor = text.slice(-8)
let previous = text.lastIndexOf(anchor, text.length - 16)
while (previous >= 0) {
const period = text.length - 8 - previous
if (period > 2048) break
const copies = period < 80 ? 8 : 4
if (period * copies <= text.length) {
const block = text.slice(-period)
if (new Set(block).size >= 6 && text.endsWith(block.repeat(copies))) return true
}
previous = previous > 0 ? text.lastIndexOf(anchor, previous - 1) : -1
}
return false
}
}
module.exports = { createOutputRepetitionGuard }
+17 -54
View File
@@ -1,12 +1,7 @@
const path = require('node:path')
const { launchEnvironment, searchPath, systemChildEnvironment } = require('./cli-discovery.cjs')
const { NETWORK_ENV_KEYS, networkEnvironment } = require('./cli-network-environment.cjs')
const { searchPath, systemChildEnvironment } = require('./cli-discovery.cjs')
const { redactChildSecrets } = require('./cli-runtime-events.cjs')
const {
agentRuntimeError,
authFailureCode,
} = require('../agent-runtime-contract.cjs')
const { authFailureCertainty, isContextOverflow } = require('../../runs/failure-signals.cjs')
const { agentRuntimeError } = require('../agent-runtime-contract.cjs')
const { validateOpenClawRuntimeGuard } = require('./openclaw-runtime.cjs')
const TERMINATE_GRACE_MS = 500
@@ -26,44 +21,32 @@ const OPENCLAW_RUNTIME_PATH_KEYS = Object.freeze([
'OPENCLAW_CONFIG_PATH',
'OPENCLAW_WORKSPACE_DIR',
])
const CONTROLLED_RUNTIME_ENV = /^(?:AWS_|AZURE_|GOOGLE_|GEMINI_|ANTHROPIC_|OPENAI_|OPENROUTER_|CODEX_|CODEBUDDY_|HERMES_|PI_|KIMI_|MIMO_|MIMOCODE_|OPENCODE_|QWEN_|DASHSCOPE_|CLAUDE_|OCR_|CLOUDSDK_|VERTEX_|CLOUD_ML_|MOONSHOT_|CONNECTOR_TEST_|MELDWORK_TEST_)/
const BLOCKED_RUNTIME_ENV = /^(?:NODE_OPTIONS|NODE_PATH|LD_PRELOAD|LD_LIBRARY_PATH|DYLD_|ELECTRON_RUN_AS_NODE)$/
function agentExecutionError(code, diagnostic = '') {
return agentRuntimeError(code, diagnostic)
}
function authConfigurationFailure(detail) {
return /api[ _-]?key|access[ _-]?token|refresh[ _-]?token|auth[ _-]?token|credential|auth(?:entication|orization)?|log(?:ged)?[ -]?in|sign(?:ed)?[ -]?in|unauthorized|forbidden|\b(?:401|403)\b|select an auth type|(?:provider|model).{0,80}(?:reject|configur|missing|invalid)|(?:reject|configur|missing|invalid).{0,80}(?:provider|model)|令牌|凭据|登录|认证|鉴权|(?:提供商|供应商|模型).{0,40}(?:配置|拒绝|缺失|无效)|(?:配置|拒绝|缺失|无效).{0,40}(?:提供商|供应商|模型)/i
.test(String(detail || ''))
}
function invalidSessionFailure(detail) {
const text = String(detail || '').trim()
if (!text || authFailureCertainty(text)
if (!text || authConfigurationFailure(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
if (/^(?:Error: )?Resume retains gateway session policy; creation overrides are unsupported on resume\./m.test(text)
&& /--yolo differs from this session's launch/.test(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)
}
function failedAgentProcessError(detail, options = {}) {
if (!detail) return agentExecutionError('LOCAL_AGENT_EXITED')
if (options.kind === 'zcode' && /\bModel provider is missing an API key:/i.test(detail)) {
return agentExecutionError('LOCAL_AGENT_AUTH_REQUIRED', 'Missing API key for the configured Zcode provider.')
}
if (options.kind === 'workbuddy' && options.model && authFailureCode(detail)) {
return agentExecutionError('LOCAL_WORKBUDDY_PROVIDER_AUTH', detail)
}
return agentExecutionError(
options.sessionRef && invalidSessionFailure(detail)
? 'LOCAL_AGENT_SESSION_INVALID'
: authFailureCode(detail) || 'LOCAL_AGENT_PROCESS_FAILED',
: authConfigurationFailure(detail)
? 'LOCAL_AGENT_AUTH_REQUIRED'
: 'LOCAL_AGENT_PROCESS_FAILED',
detail,
)
}
@@ -77,10 +60,7 @@ function openClawChildEnvironment(workdir, options, platform) {
throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
}
const env = {
...systemChildEnvironment(process.env, platform),
...networkEnvironment(source, platform),
}
const env = systemChildEnvironment(process.env, platform)
for (const key of [
'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH',
'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME',
@@ -118,33 +98,16 @@ function childEnvironment(agent, workdir, options, platform) {
? { HERMES_EXEC_ASK: '', HERMES_YOLO_MODE: '1' }
: { HERMES_EXEC_ASK: '1', HERMES_YOLO_MODE: '' }
: {}
const openCodeSafetyEnv = {}
// ACP has no terminal UI for question/plan transitions. Match the native
// headless runner: return the answer to Meldwork instead of waiting forever.
const headlessPermissions = { question: 'deny', plan_enter: 'deny', plan_exit: 'deny' }
if (agent.kind === 'opencode') {
openCodeSafetyEnv.OPENCODE_PERMISSION = JSON.stringify({
...(options.sandbox !== 'workspace-write' ? JSON.parse(OPENCODE_READ_ONLY_PERMISSION) : {}),
...headlessPermissions,
})
}
if (agent.kind === 'mimo') {
// MiMo applies read/write permissions with ACP session/set_mode. Keep
// process-wide permissions stable when the discussion changes phase.
openCodeSafetyEnv.MIMOCODE_PERMISSION = JSON.stringify(headlessPermissions)
}
const openCodeSafetyEnv = agent.kind === 'opencode'
&& options.sandbox !== 'workspace-write'
? { OPENCODE_PERMISSION: OPENCODE_READ_ONLY_PERMISSION }
: {}
const source = { ...process.env, ...options.env }
const controlled = Object.fromEntries(Object.entries(options.env || {}).filter(([key, value]) => (
typeof value === 'string'
&& (CONTROLLED_RUNTIME_ENV.test(key) || NETWORK_ENV_KEYS.includes(key))
&& !BLOCKED_RUNTIME_ENV.test(key)
)))
return {
...systemChildEnvironment(process.env, platform),
...controlled,
...options.env,
...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(), reasoning: new Map() }
return { tools: new Map() }
}
const ACP_TOOL_KINDS = new Set([
@@ -447,22 +447,10 @@ function acpToolOperation(update) {
function acpRuntimeEvents(update, state) {
if (!update || typeof update !== 'object') return []
if (update.sessionUpdate === 'agent_message_chunk' && update.content?.type === '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 },
]
return [{ type: 'answer_delta', status: 'running', delta: update.content.text }]
}
if (update.sessionUpdate === 'agent_thought_chunk' && update.content?.type === 'text') {
// 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] : []
return []
}
if (update.sessionUpdate === 'tool_call' || update.sessionUpdate === 'tool_call_update') {
const status = runtimeEventStatus(update.status)
@@ -28,38 +28,21 @@ function utf8ByteLength(value) {
function redactChildSecrets(value, env) {
let result = String(value || '')
for (const secret of childSecrets(env)) {
for (const [name, secret] of Object.entries(env)) {
if (!/(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)/i.test(name)
|| typeof secret !== 'string' || utf8ByteLength(secret) < 8) continue
result = result.split(secret).join('[redacted]')
}
return result
}
function childSecrets(env) {
const secrets = new Set()
for (const [name, value] of Object.entries(env || {})) {
if (typeof value !== 'string' || !value) continue
if (/(?:API[_-]?KEY|ACCESS[_-]?KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)/i.test(name)
&& utf8ByteLength(value) >= 8) secrets.add(value)
if (!/^(?:https?|all)_proxy$/i.test(name)) continue
if (value.includes('@')) secrets.add(value)
try {
const url = new URL(value)
if (!url.username && !url.password) continue
secrets.add(value)
secrets.add(url.href)
const userinfo = `${url.username}:${url.password}`
secrets.add(userinfo)
const decoded = decodeURIComponent(userinfo)
secrets.add(decoded)
secrets.add(Buffer.from(decoded).toString('base64'))
for (const part of [url.username, url.password]) {
if (utf8ByteLength(part) >= 8) secrets.add(part)
const literal = decodeURIComponent(part)
if (utf8ByteLength(literal) >= 8) secrets.add(literal)
}
} catch { /* malformed proxy URLs are diagnosed by the native runtime */ }
}
return [...secrets].sort((left, right) => right.length - left.length)
return Object.entries(env || {})
.filter(([name, secret]) => (
/(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)/i.test(name)
&& typeof secret === 'string' && utf8ByteLength(secret) >= 8
))
.map(([, secret]) => secret)
}
function assignmentNameVariants(parts) {
@@ -99,7 +82,7 @@ function pathSuffixLength(value) {
const matches = [
value.match(/file:\/\/\/[^\s"'`<>|,;)}\]]+$/i),
value.match(/(?<![A-Za-z0-9:])(?:[A-Za-z]:[\\/]|\\\\)[^\s"'`<>|,;)}\]]+$/),
value.match(/(?<![\p{L}\p{N}:/])\/+[^\s"'`<>|,;)}\]]+$/u),
value.match(/(?<![A-Za-z0-9:/])\/+[^\s"'`<>|,;)}\]]+$/),
]
return Math.max(...matches.map(match => match?.[0]?.length || 0))
}
@@ -107,9 +90,6 @@ function pathSuffixLength(value) {
function boundarySensitiveSuffixLength(value, secrets) {
let retained = 0
const retain = (length) => { retained = Math.max(retained, length || 0) }
// Keep a word boundary across chunks: a slash in the next chunk may be
// prose (云/端), not the start of an absolute filesystem path.
retain(value.match(/(?=[^\x00-\x7f])[\p{L}\p{N}]$/u)?.[0]?.length)
for (const secret of secrets) {
const limit = Math.min(value.length, secret.length - 1)
for (let length = limit; length > retained; length -= 1) {
@@ -126,7 +106,7 @@ function boundarySensitiveSuffixLength(value, secrets) {
}
const pathLength = pathSuffixLength(value)
if (pathLength) retain(pathLength)
retain(value.match(/(?<![\p{L}\p{N}:/])\/+$/u)?.[0]?.length)
retain(value.match(/(?<![A-Za-z0-9:/])\/+$/)?.[0]?.length)
const matches = [
value.match(/\bAKIA[0-9A-Z]{0,15}$/),
@@ -209,7 +189,7 @@ function sanitizeRuntimeEventText(value, childEnv, limit, singleLine = false, tr
.replace(/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/gi, '[redacted private key]')
.replace(/file:\/\/\/[^\s"'`<>|,;)}\]]+/gi, '[path]')
.replace(/(?<![A-Za-z0-9:])(?:[A-Za-z]:[\\/]|\\\\)[^\s"'`<>|,;)}\]]+/g, '[path]')
.replace(/(?<![\p{L}\p{N}:/])\/+[^\s"'`<>|,;)}\]]+/gu, '[path]')
.replace(/(?<![A-Za-z0-9:/])\/+[^\s"'`<>|,;)}\]]+/g, '[path]')
.replace(/(["']?)(?:[A-Za-z0-9_.-]*(?:api[_-]?key|access[_-]?key(?:[_-]?id)?|secret[_-]?access[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|database[_-]?url|connection[_-]?string|dsn|token|secret|password|credential|authorization|private[_-]?key)[A-Za-z0-9_.-]*)\1\s*[:=]\s*(?:"[^"\r\n]*"|'[^'\r\n]*'|\[[^\]\r\n]*\]|[^\s,;}\]]+)/gi, 'credential=[redacted]')
.replace(/\bAKIA[0-9A-Z]{16}\b/g, '[redacted]')
.replace(/\bbearer\s+[A-Za-z0-9._~+\/-]+=*/gi, 'Bearer [redacted]')
@@ -13,7 +13,6 @@ const {
parseMimoOutput,
parseOpenCodeOutput,
parseOpenCodeReviewOutput,
parsePiOutput,
parseWorkBuddyOutput,
readHermesFinalResponse,
readHermesMessageWatermark,
@@ -55,7 +54,6 @@ module.exports = {
parseMimoOutput,
parseOpenCodeOutput,
parseOpenCodeReviewOutput,
parsePiOutput,
parseWorkBuddyOutput,
readHermesFinalResponse,
readHermesMessageWatermark,
@@ -1,41 +0,0 @@
const { randomUUID } = require('node:crypto')
// A contribution ends only on an explicit, invocation-specific signal from
// the assistant. Tool output and acknowledgements cannot finish a turn.
function createTurnCompletion() {
const marker = `[[MELDWORK_TURN_DONE:${randomUUID()}]]`
let text = ''
let delivered = 0
let completed = false
return {
instruction: `This is one scheduled contribution. Other participants cannot run until you return. Complete only the contribution requested for this invocation; do not poll or wait for their approvals. After your answer and any required collaboration receipt, output this exact signal on its own final line: ${marker}\nThe signal ends only your invocation, never the group task. Do not quote or explain it, and do not output it before all your tools have finished.`,
push(delta, toolsFinished = true) {
if (completed) return { delta: '', completed: true }
text += delta
const index = text.lastIndexOf(marker)
if (toolsFinished && index > 0 && text[index - 1] === '\n'
&& /^(?:[ \t]*\r?\n|[ \t]*$)/.test(text.slice(index + marker.length))) {
const before = text.slice(0, index)
const fences = before.match(/^\s*(?:```|~~~)/gm) || []
if (fences.length % 2 === 0 && before.trim()) {
completed = true
const visible = before.slice(delivered)
text = before
delivered = text.length
return { delta: visible, completed: true }
}
}
let held = 0
for (let length = Math.min(marker.length, text.length); length > 0; length--) {
if (text.endsWith(marker.slice(0, length))) { held = length; break }
}
const end = text.length - held
const visible = text.slice(delivered, end)
delivered = end
return { delta: visible, completed: false }
},
get completed() { return completed },
}
}
module.exports = { createTurnCompletion }
@@ -1,23 +0,0 @@
const fs = require('node:fs')
const path = require('node:path')
const os = require('node:os')
function mimoConfiguredProvider(options = {}) {
const env = options.env || process.env
const root = env.XDG_CONFIG_HOME || path.join(options.home || os.homedir(), '.config')
try {
const file = path.join(root, 'mimocode', 'mimocode.json')
if (fs.statSync(file).size > 2 * 1024 * 1024) return false
const config = JSON.parse(fs.readFileSync(file, 'utf8'))
if (typeof config.model !== 'string' || !config.model.includes('/')) return false
const provider = config.provider?.[config.model.split('/')[0]]
let key = provider?.options?.apiKey
if (typeof key !== 'string' || !key.trim()) return false
const reference = key.match(/^\{env:([A-Za-z_][A-Za-z0-9_]*)\}$/)
if (reference) key = env[reference[1]]
else if (/^\{.*\}$/.test(key)) return false
return typeof key === 'string' && Boolean(key.trim())
} catch { return false }
}
module.exports = { mimoConfiguredProvider }
+98 -81
View File
@@ -1,10 +1,10 @@
const crypto = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
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',
]
@@ -36,12 +36,12 @@ 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',
])
const issuedOpenClawRuntimeGuards = new WeakSet()
const openClawRuntimeCredentialDigests = new WeakMap()
const openClawRuntimeConfigs = new WeakMap()
const OPENCLAW_ENV_SECRET_NAME = /^[A-Z][A-Z0-9_]{0,127}$/
const OPENCLAW_SECRET_MARKERS = new Set([
'secretref-managed',
@@ -89,7 +89,7 @@ function directoryIdentity(filename) {
})
}
function readRuntimeConfigFile(filename) {
function fileIdentity(filename) {
const resolved = path.resolve(filename)
const entry = fs.lstatSync(resolved)
if (!entry.isFile() || entry.isSymbolicLink() || fs.realpathSync(resolved) !== resolved) {
@@ -99,22 +99,14 @@ function readRuntimeConfigFile(filename) {
const descriptor = fs.openSync(resolved, fs.constants.O_RDONLY | noFollow)
try {
const before = fs.fstatSync(descriptor)
if (!before.isFile() || before.size > 256 * 1024 || !sameFilesystemIdentity(before, {
if (!before.isFile() || !sameFilesystemIdentity(before, {
dev: identityValue(entry.dev),
ino: identityValue(entry.ino),
birthtime: identityValue(entry.birthtimeMs),
})) {
throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
}
const buffer = Buffer.alloc(256 * 1024 + 1)
let length = 0
while (length < buffer.length) {
const count = fs.readSync(descriptor, buffer, length, buffer.length - length, null)
if (!count) break
length += count
}
if (length === buffer.length) throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
const contents = buffer.subarray(0, length)
const contents = fs.readFileSync(descriptor)
const after = fs.fstatSync(descriptor)
const current = fs.lstatSync(resolved)
if (!sameFilesystemIdentity(after, {
@@ -129,7 +121,7 @@ function readRuntimeConfigFile(filename) {
}) || fs.realpathSync(resolved) !== resolved) {
throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
}
const identity = Object.freeze({
return Object.freeze({
path: resolved,
dev: identityValue(before.dev),
ino: identityValue(before.ino),
@@ -138,16 +130,11 @@ function readRuntimeConfigFile(filename) {
size: contents.length,
digest: crypto.createHash('sha256').update(contents).digest('hex'),
})
return { identity, contents: contents.toString('utf8') }
} finally {
fs.closeSync(descriptor)
}
}
function fileIdentity(filename) {
return readRuntimeConfigFile(filename).identity
}
function validateDirectoryIdentities(identities) {
for (const expected of identities) {
const current = directoryIdentity(expected.path)
@@ -171,7 +158,26 @@ function validateFileIdentity(expected) {
}
}
function runtimePaths({ storageRoot, workdir, sessionRef, configName, runtimeVersion }) {
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 }) {
if (!path.isAbsolute(storageRoot) || !path.isAbsolute(workdir)) {
throw new Error('OPENCLAW_RUNTIME_INVALID_SCOPE')
}
@@ -183,15 +189,8 @@ function runtimePaths({ storageRoot, workdir, sessionRef, configName, runtimeVer
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(versionSegment
? `${sessionRef || 'configure'}\0${resolvedWorkdir}\0${versionSegment}`
: `${sessionRef || 'configure'}\0${resolvedWorkdir}`)
.update(`${sessionRef || 'configure'}\0${resolvedWorkdir}`)
.digest('hex')
.slice(0, 24)
const runtimeRoot = path.join(resolvedStorageRoot, 'openclaw-managed', scope)
@@ -269,7 +268,6 @@ function writeRuntimeConfig(runtime, config, credentials, credentialKey) {
credentialKeys: Object.freeze(credentialEntries.map(([key]) => key)),
})
issuedOpenClawRuntimeGuards.add(guard)
openClawRuntimeConfigs.set(guard, contents)
openClawRuntimeCredentialDigests.set(guard, Object.freeze(Object.fromEntries(
credentialEntries.map(([key, value]) => [
key,
@@ -279,7 +277,7 @@ function writeRuntimeConfig(runtime, config, credentials, credentialKey) {
return guard
}
function validateRuntimeScope(guard, env = {}) {
function validateOpenClawRuntimeGuard(guard, env = {}) {
const credentialDigests = guard && typeof guard === 'object'
? openClawRuntimeCredentialDigests.get(guard)
: undefined
@@ -303,57 +301,10 @@ function validateRuntimeScope(guard, env = {}) {
}
}
validateDirectoryIdentities(guard.directories)
}
function validateOpenClawRuntimeGuard(guard, env = {}) {
validateRuntimeScope(guard, env)
validateFileIdentity(guard.config)
return true
}
function comparableMigratedConfig(config) {
// OpenClaw materializes the implicit main agent and stamps migrations at startup.
const result = JSON.parse(JSON.stringify(config))
const meta = result.meta
if (meta && typeof meta === 'object' && !Array.isArray(meta)
&& Object.keys(meta).every(key => ['migrations', 'lastTouchedVersion', 'lastTouchedAt'].includes(key))
&& (meta.lastTouchedVersion === undefined || /^\d{4}\.\d{1,2}\.\d{1,2}(?:[-+.][\w.-]+)?$/.test(meta.lastTouchedVersion))
&& (meta.lastTouchedAt === undefined || (typeof meta.lastTouchedAt === 'string' && Number.isFinite(Date.parse(meta.lastTouchedAt))))
&& (meta.migrations === undefined || isDeepStrictEqual(meta.migrations, { modelPolicyAllowlist: true }))) {
delete result.meta
}
if (isDeepStrictEqual(result.agents?.entries, { main: {} })) delete result.agents.entries
if (isDeepStrictEqual(result.discovery?.wideArea, { enabled: false })) {
result.discovery.wideArea = {}
}
return result
}
function refreshOpenClawRuntimeAfterStartup(options) {
const guard = options?.openClawRuntimeGuard
const env = options?.env || {}
validateRuntimeScope(guard, env)
const { identity: config, contents } = readRuntimeConfigFile(guard.config.path)
if (process.platform !== 'win32' && (config.mode & 0o777) !== 0o600) {
throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
}
validateDirectoryIdentities(guard.directories)
validateFileIdentity(config)
let equivalent = false
try {
equivalent = isDeepStrictEqual(
comparableMigratedConfig(JSON.parse(openClawRuntimeConfigs.get(guard))),
comparableMigratedConfig(JSON.parse(contents)),
)
} catch { /* malformed config is rejected below */ }
if (!equivalent) throw new Error('OPENCLAW_RUNTIME_UNSAFE_PATH')
const refreshed = Object.freeze({ ...guard, config })
issuedOpenClawRuntimeGuards.add(refreshed)
openClawRuntimeConfigs.set(refreshed, contents)
openClawRuntimeCredentialDigests.set(refreshed, openClawRuntimeCredentialDigests.get(guard))
return { ...options, openClawRuntimeGuard: refreshed }
}
function configureOpenClawGatewayRuntime(options, port) {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('OPENCLAW_GATEWAY_PORT_INVALID')
@@ -362,7 +313,7 @@ function configureOpenClawGatewayRuntime(options, port) {
const env = options?.env || {}
validateOpenClawRuntimeGuard(guard, env)
const config = JSON.parse(openClawRuntimeConfigs.get(guard))
const config = JSON.parse(fs.readFileSync(guard.config.path, 'utf8'))
const gatewayCredentialKey = 'OPENCLAW_GATEWAY_TOKEN'
const nextConfig = {
...config,
@@ -444,8 +395,74 @@ 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, openclawVersion,
storageRoot, workdir, sessionRef = '', allowWrite = false, runtime,
}) {
const modelRef = String(runtime?.model || '').trim()
const provider = runtime?.provider
@@ -477,7 +494,7 @@ function nativeOpenClawOptions({
? 'openclaw.native.workspace-write.json'
: 'openclaw.native.read-only.json'
const runtimePathsResult = runtimePaths({
storageRoot, workdir, sessionRef, configName, runtimeVersion: openclawVersion,
storageRoot, workdir, sessionRef, configName,
})
const { resolvedWorkdir, home, state, configPath } = runtimePathsResult
const modelConfig = { primary: modelRef }
@@ -538,7 +555,7 @@ function nativeOpenClawOptions({
module.exports = {
configureOpenClawGatewayRuntime,
isOpenClawSecretReference,
managedOpenClawOptions,
nativeOpenClawOptions,
refreshOpenClawRuntimeAfterStartup,
validateOpenClawRuntimeGuard,
}
@@ -1,89 +0,0 @@
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { launchEnvironment } = require('./cli-discovery.cjs')
const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,119}$/
function workBuddyModels(options = {}) {
const env = options.env || process.env
const root = env.CODEBUDDY_CONFIG_DIR || path.join(options.home || os.homedir(), '.workbuddy')
try {
const filename = path.join(root, 'models.json')
const stat = fs.statSync(filename)
if (!stat.isFile() || stat.size > 2 * 1024 * 1024) return []
const value = JSON.parse(fs.readFileSync(filename, 'utf8'))
const models = Array.isArray(value) ? value : value.models
return (Array.isArray(models) ? models : []).filter(model => (
typeof model?.id === 'string' && MODEL_ID.test(model.id)
&& typeof model.apiKey === 'string' && model.apiKey.trim()
&& model.supportsToolCall === true && validUrl(model.url)
)).slice(0, 64)
} catch { return [] }
}
function validUrl(value) {
try {
const url = new URL(value)
return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password
} catch { return false }
}
function workBuddySettings(options = {}) {
return {
modelId: options.modelId || '',
models: workBuddyModels(options).map(model => ({
id: model.id, name: String(model.name || model.id).slice(0, 120),
})),
}
}
function workBuddyEnvironment(options = {}) {
if (!options.modelId) return {}
const model = workBuddyModels(options).find(model => model.id === options.modelId)
if (!model) throw new Error('LOCAL_WORKBUDDY_MODEL_UNAVAILABLE')
// Use the CLI's supported provider settings, without copying desktop account
// tokens or attempting to unlock its host-managed encrypted credential store.
return {
CODEBUDDY_CONFIG_DIR: (options.env || process.env).CODEBUDDY_CONFIG_DIR
|| path.join(options.home || os.homedir(), '.workbuddy'),
CODEBUDDY_MODEL: `custom-local:${model.id}`,
CODEBUDDY_API_KEY: model.apiKey,
CODEBUDDY_BASE_URL: model.url,
}
}
function workBuddyCredentialState(options = {}) {
if (options.modelId) {
try {
workBuddyEnvironment(options)
return { state: 'ready', source: 'native-credential' }
} catch { return { state: 'unknown', source: 'workbuddy-model-unavailable' } }
}
const env = options.env || process.env
if (env.CODEBUDDY_API_KEY?.trim() || env.CODEBUDDY_AUTH_TOKEN?.trim()) {
return { state: 'ready', source: 'native-credential' }
}
// A models.json entry is not a selected provider. The desktop app's shared
// encrypted login also cannot be decoded by its standalone lite CLI.
if (launchEnvironment('workbuddy', options.executable, options).CODEBUDDY_FORCE_LITE_WB_BUNDLE !== '1') {
return { state: 'unknown', source: 'unverified' }
}
const home = options.home || os.homedir()
const platform = options.platform || process.platform
const root = platform === 'darwin' ? path.join(home, 'Library', 'Application Support')
: platform === 'win32' ? path.join(home, 'AppData', 'Local')
: path.join(home, '.local', 'share')
try {
const filename = path.join(root, 'CodeBuddyExtension', 'Data', 'Public', 'auth', 'workbuddy-desktop.info')
if (fs.statSync(filename).size <= 2 * 1024 * 1024) {
const auth = JSON.parse(fs.readFileSync(filename, 'utf8')).auth
if (auth?.accessToken && typeof auth.accessToken === 'object') {
return { state: 'unknown', source: 'workbuddy-desktop-locked' }
}
}
} catch { /* native CLI may still have an independent login */ }
return { state: 'unknown', source: 'unverified' }
}
module.exports = { workBuddyCredentialState, workBuddyEnvironment, workBuddySettings }
@@ -632,6 +632,8 @@ class CloudAgentBridgeStore {
installed: true,
installSupported: false,
installErrorCode: '',
providerCompatible: false,
providerMode: 'connector',
imageAttachmentLimit: 0,
custom: false,
connector: true,
@@ -49,7 +49,7 @@ const SAMPLE_AGENT_CONNECTOR_MANIFEST = createAgentConnectorManifest({
},
outboundDestinations: [],
credentials: { mode: 'none', slots: [] },
license: 'Apache-2.0',
license: 'LicenseRef-Meldwork-NC',
})
const SAMPLE_CREDENTIAL_AGENT_CONNECTOR_MANIFEST = createAgentConnectorManifest({
connectorId: 'external.codex-provider-sample',
@@ -82,7 +82,7 @@ const SAMPLE_CREDENTIAL_AGENT_CONNECTOR_MANIFEST = createAgentConnectorManifest(
mode: 'credential-ref',
slots: [{ slotId: 'openai-api-key', type: 'api-key', required: true }],
},
license: 'Apache-2.0',
license: 'LicenseRef-Meldwork-NC',
})
const APPROVED_AGENT_CONNECTOR_MANIFESTS = Object.freeze([
SAMPLE_AGENT_CONNECTOR_MANIFEST,
@@ -117,7 +117,7 @@ const SAMPLE_LOCAL_ECHO_AGENT_CONNECTOR_MANIFEST = createAgentConnectorManifest(
},
outboundDestinations: [],
credentials: { mode: 'none', slots: [] },
license: 'Apache-2.0',
license: 'LicenseRef-Meldwork-NC',
})
const SAMPLE_LOCAL_ECHO_AGENT_CONNECTOR_PACKAGE = createAgentConnectorPackage({
publisher: { id: 'meldwork', name: 'Meldwork' },
@@ -603,6 +603,8 @@ class LocalAgentConnectors {
installed: true,
installSupported: false,
installErrorCode: '',
providerCompatible: false,
providerMode: 'connector',
imageAttachmentLimit: manifest.inputTypes.includes('image') ? 32 : 0,
custom: !cloud,
connector: true,
@@ -127,6 +127,9 @@ function publicProfile(definition, version = '') {
promptMode: definition.promptMode,
custom: true,
recommended: false,
providerCompatible: false,
providerSupport: 'custom-cli',
providerMode: 'custom',
installed: true,
installSupported: false,
installErrorCode: '',
@@ -4,17 +4,6 @@ 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
@@ -110,17 +99,50 @@ 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 },
{ 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 },
{
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',
},
])
function installRecipe(kind, platform) {
@@ -350,7 +372,6 @@ function verifiedRecipeAgent(agent, recipe) {
}
module.exports = {
installedVersionCurrent,
AGENT_CATALOG,
DETECTION_CACHE_TTL_MS,
abortError,
@@ -3,7 +3,6 @@ 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,
@@ -61,15 +60,12 @@ function publicCompatibility(agent) {
? agent.versionIdentified
: Boolean(resolvedVersion)
const compatible = compatibilityState === 'compatible'
const incompatibilityReason = compatibilityState === 'unknown'
&& agent.incompatibilityReason === 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT'
? 'LOCAL_AGENT_CAPABILITY_PROBE_TIMEOUT'
: compatibilityState === 'incompatible'
const incompatibilityReason = compatibilityState === 'incompatible'
? (COMPATIBILITY_REASONS.has(agent.incompatibilityReason)
? agent.incompatibilityReason
: 'LOCAL_AGENT_VERSION_UNSUPPORTED')
: ''
const incompatibilityProbe = Boolean(incompatibilityReason)
const incompatibilityProbe = compatibilityState === 'incompatible'
&& /^[a-z0-9-]{1,80}$/.test(String(agent.incompatibilityProbe || ''))
? agent.incompatibilityProbe
: ''
@@ -168,10 +164,6 @@ class AgentInstaller extends EventEmitter {
agents: AGENT_CATALOG.map(profile => {
const agent = installed.get(profile.kind)
const recipe = installRecipe(profile.kind, this.platform)
const targetVersion = recipe?.detectedVersion || recipe?.version || ''
const currentVersion = String(agent?.version || '')
const upToDate = Boolean(agent && targetVersion
&& installedVersionCurrent(currentVersion, targetVersion))
let installSupported = Boolean(recipe)
let installErrorCode = ''
if (!recipe) {
@@ -185,8 +177,6 @@ class AgentInstaller extends EventEmitter {
...profile,
installed: Boolean(agent),
version: agent?.version || '',
targetVersion,
installAction: agent ? (upToDate ? 'current' : 'update') : 'install',
...publicCompatibility(agent),
installSupported,
installErrorCode,
@@ -240,10 +230,7 @@ class AgentInstaller extends EventEmitter {
this.invalidateDetectionCache()
const installed = await abortable(this.detectedAgents(), signal)
if (signal.aborted) throw abortError()
const current = installed.find(agent => agent.kind === profile.kind)
const targetVersion = recipe.detectedVersion || recipe.version
if (current && (current.compatibilityState !== 'compatible'
|| installedVersionCurrent(current.version, targetVersion))) {
if (installed.some(agent => agent.kind === profile.kind)) {
throw installerError('INSTALL_AGENT_ALREADY_INSTALLED')
}
let command = recipe.interpreter || ''
+41 -147
View File
@@ -1,14 +1,10 @@
const { execFile } = require('node:child_process')
const { createHash } = require('node:crypto')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { promisify } = require('node:util')
const { prepareCommand, searchPath } = require('./cli/cli-adapters.cjs')
const { isOpenClawSecretReference } = require('./cli/openclaw-runtime.cjs')
const { NETWORK_ENV_KEYS, networkEnvironment } = require('./cli/cli-network-environment.cjs')
const { workBuddyCredentialState } = require('./cli/workbuddy-runtime.cjs')
const { mimoConfiguredProvider } = require('./cli/mimo-native-config.cjs')
const execFileAsync = promisify(execFile)
@@ -31,21 +27,13 @@ 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({
codex: ['OPENAI_API_KEY'],
hermes: ['OPENAI_API_KEY', 'OPENROUTER_API_KEY', 'ANTHROPIC_API_KEY'],
openclaw: ['OPENAI_API_KEY', 'OPENROUTER_API_KEY'],
workbuddy: ['CODEBUDDY_API_KEY', 'CODEBUDDY_AUTH_TOKEN'],
zcode: ['ANTHROPIC_API_KEY'],
pi: ['PI_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY', 'GEMINI_API_KEY'],
workbuddy: ['CODEBUDDY_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY'],
pi: ['PI_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY'],
kimi: ['MOONSHOT_API_KEY', 'KIMI_API_KEY', 'KIMI_MODEL_API_KEY', 'OPENAI_API_KEY'],
mimo: ['MIMO_API_KEY'],
claude: ['ANTHROPIC_API_KEY'],
@@ -54,60 +42,33 @@ const CREDENTIAL_ENV_KEYS = Object.freeze({
opencode: ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY'],
opencodereview: ['OCR_LLM_TOKEN', 'OPENAI_API_KEY'],
})
const GOOGLE_CLOUD_ENV_KEYS = [
'GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GCLOUD_PROJECT',
'GOOGLE_CLOUD_LOCATION', 'GOOGLE_CLOUD_QUOTA_PROJECT', 'CLOUDSDK_CONFIG',
]
const RUNTIME_ENV_KEYS = Object.freeze({
codex: ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', 'CODEX_HOME'],
codex: ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL'],
hermes: [
'OPENAI_API_KEY', 'OPENROUTER_API_KEY', 'ANTHROPIC_API_KEY',
'OPENAI_BASE_URL', 'OPENAI_MODEL', 'HERMES_INFERENCE_PROVIDER', 'HERMES_INFERENCE_MODEL',
'HERMES_HOME',
],
openclaw: ['OPENAI_API_KEY', 'OPENROUTER_API_KEY'],
workbuddy: [
'CODEBUDDY_API_KEY', 'CODEBUDDY_AUTH_TOKEN', 'CODEBUDDY_BASE_URL', 'CODEBUDDY_MODEL',
'CODEBUDDY_CONFIG_DIR',
'CODEBUDDY_API_KEY', 'CODEBUDDY_BASE_URL', 'CODEBUDDY_MODEL',
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', 'ANTHROPIC_API_KEY',
],
zcode: ['ANTHROPIC_API_KEY'],
pi: [
'PI_API_KEY', 'PI_BASE_URL', 'PI_MODEL',
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', 'ANTHROPIC_API_KEY',
'OPENROUTER_API_KEY', 'GEMINI_API_KEY', 'PI_CODING_AGENT_DIR',
],
kimi: [
'MOONSHOT_API_KEY', 'KIMI_API_KEY', 'KIMI_MODEL_API_KEY',
'KIMI_MODEL_BASE_URL', 'KIMI_MODEL_NAME',
'KIMI_MODEL_PROVIDER_TYPE', 'KIMI_MODEL_CAPABILITIES',
'KIMI_MODEL_MAX_CONTEXT_SIZE', 'KIMI_MODEL_MAX_OUTPUT_SIZE',
'KIMI_CODE_HOME',
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL',
],
mimo: ['MIMO_API_KEY', 'MIMO_BASE_URL', 'MIMO_MODEL', 'MIMOCODE_CONFIG_DIR', 'MIMOCODE_CONFIG_CONTENT'],
claude: [
'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN',
'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL', 'CLAUDE_CONFIG_DIR',
'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', 'CLAUDE_CODE_USE_MANTLE',
'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', 'AWS_BEARER_TOKEN_BEDROCK',
'AWS_PROFILE', 'AWS_REGION', 'AWS_DEFAULT_REGION', 'AWS_CONFIG_FILE', 'AWS_SHARED_CREDENTIALS_FILE',
'AWS_ROLE_ARN', 'AWS_ROLE_SESSION_NAME', 'AWS_WEB_IDENTITY_TOKEN_FILE',
'ANTHROPIC_BEDROCK_BASE_URL', 'ANTHROPIC_BEDROCK_MANTLE_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION',
...GOOGLE_CLOUD_ENV_KEYS,
'CLOUD_ML_REGION', 'ANTHROPIC_VERTEX_PROJECT_ID', 'ANTHROPIC_VERTEX_BASE_URL',
'ANTHROPIC_FOUNDRY_RESOURCE', 'ANTHROPIC_FOUNDRY_BASE_URL',
'ANTHROPIC_FOUNDRY_API_KEY', 'ANTHROPIC_FOUNDRY_AUTH_TOKEN',
'AZURE_CLIENT_ID', 'AZURE_TENANT_ID', 'AZURE_CLIENT_SECRET', 'AZURE_AUTHORITY_HOST',
'AZURE_CLIENT_CERTIFICATE_PATH', 'AZURE_CLIENT_CERTIFICATE_PASSWORD', 'AZURE_FEDERATED_TOKEN_FILE',
'AZURE_CONFIG_DIR',
],
qwen: ['DASHSCOPE_API_KEY', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', 'QWEN_HOME'],
gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_MODEL', 'GOOGLE_GEMINI_BASE_URL', ...GOOGLE_CLOUD_ENV_KEYS],
mimo: ['MIMO_API_KEY', 'MIMO_BASE_URL', 'MIMO_MODEL'],
claude: ['ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL'],
qwen: ['DASHSCOPE_API_KEY', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL'],
gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_MODEL'],
opencode: [
'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY',
'OPENAI_BASE_URL', 'OPENAI_MODEL',
'OPENCODE_CONFIG', 'OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG_CONTENT',
],
opencodereview: [
'OCR_LLM_TOKEN', 'OCR_LLM_URL', 'OCR_LLM_MODEL', 'OCR_USE_ANTHROPIC',
@@ -116,7 +77,6 @@ const RUNTIME_ENV_KEYS = Object.freeze({
})
const SHELL_ENV_KEYS = Object.freeze([...new Set([
'PATH',
...NETWORK_ENV_KEYS,
...Object.values(RUNTIME_ENV_KEYS).flat(),
])])
let shellEnvironmentCache = null
@@ -134,8 +94,6 @@ function readCredentialFile(filename) {
function containsCredential(value) {
if (!value || typeof value !== 'object') return false
if (Array.isArray(value)) return value.some(containsCredential)
if (value.type === 'api_key' && credentialLiteral(value.key)) return true
if (value.type === 'oauth' && (credentialLiteral(value.access) || credentialLiteral(value.refresh))) return true
return Object.entries(value).some(([key, child]) => {
if (CREDENTIAL_FIELD.test(key) && credentialLiteral(child)) return true
return child && typeof child === 'object' && containsCredential(child)
@@ -175,10 +133,8 @@ function textContainsCredential(filename) {
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 (typeof env[key] === 'string') result[key] = env[key]
for (const key of RUNTIME_ENV_KEYS[kind] || []) {
if (typeof env[key] === 'string' && env[key].trim()) result[key] = env[key]
}
return result
}
@@ -191,41 +147,23 @@ function nativeCredentialKeyEnvironment(kind, env = process.env) {
return result
}
function allowedShellEnvironment(env = process.env, platform = process.platform) {
const source = { ...env, ...networkEnvironment(env, platform) }
function allowedShellEnvironment(env = process.env) {
const result = {}
for (const key of [...SHELL_ENV_KEYS, ...Object.keys(source).filter(key => VERTEX_MODEL_REGION_KEY.test(key))]) {
const value = source[key]
if (typeof value !== 'string' || value.length > MAX_SHELL_ENV_BYTES) continue
for (const key of SHELL_ENV_KEYS) {
const value = env[key]
if (typeof value !== 'string' || !value || value.length > MAX_SHELL_ENV_BYTES) continue
result[key] = value
}
return result
}
function nativeShellCommand(shellName) {
function nativeShellCommand() {
const keys = SHELL_ENV_KEYS.join(' ')
// Enumerate names only; do not dump unrelated values or depend on Electron RunAsNode.
const regionNames = shellName === 'zsh'
? 'builtin print -rl -- ${(k)parameters[(I)VERTEX_REGION_*]}'
: [
'if [ -n "${BASH_VERSION-}" ]; then',
' builtin compgen -e VERTEX_REGION_',
'elif [ -x /bin/bash ]; then',
" BASH_ENV= ENV= /bin/bash --noprofile --norc -c 'builtin compgen -e VERTEX_REGION_'",
'fi',
].join('\n')
return [
`printf '${SHELL_ENV_MARKER}\\0'`,
`for __meldwork_key in ${keys} $(${regionNames}); do`,
' case "$__meldwork_key" in ""|*[!A-Za-z0-9_]*) continue ;; esac',
' case "$__meldwork_key" in VERTEX_REGION_*)',
' __meldwork_model=${__meldwork_key#VERTEX_REGION_}',
' case "$__meldwork_model" in ""|*[!A-Z0-9_]*) continue ;; esac',
' [ ${#__meldwork_model} -le 120 ] || continue ;;',
' esac',
`for __meldwork_key in ${keys}; do`,
' eval "__meldwork_value=\\${$__meldwork_key-}"',
' eval "__meldwork_set=\\${$__meldwork_key+x}"',
' if [ -n "$__meldwork_set" ]; then',
' if [ -n "$__meldwork_value" ]; then',
" printf '%s=%s\\0' \"$__meldwork_key\" \"$__meldwork_value\"",
' fi',
'done',
@@ -244,8 +182,7 @@ function parseNativeShellEnvironment(output) {
if (separator <= 0) continue
const key = entry.slice(0, separator)
const value = entry.slice(separator + 1)
if ((!allowed.has(key) && !VERTEX_MODEL_REGION_KEY.test(key))
|| value.length > MAX_SHELL_ENV_BYTES) continue
if (!allowed.has(key) || !value || value.length > MAX_SHELL_ENV_BYTES) continue
result[key] = value
}
return result
@@ -255,7 +192,7 @@ async function queryNativeShellEnvironment(options = {}) {
const source = options.env || process.env
const platform = options.platform || process.platform
const home = options.home || os.homedir()
const fallback = allowedShellEnvironment(source, platform)
const fallback = allowedShellEnvironment(source)
if (platform === 'win32') return { env: fallback, source: 'process' }
const shell = String(options.shell || source.SHELL || (platform === 'darwin' ? '/bin/zsh' : '/bin/sh'))
const shellName = path.basename(shell)
@@ -270,9 +207,9 @@ async function queryNativeShellEnvironment(options = {}) {
try {
const result = await execFileFn(
shell,
[shellName === 'sh' ? '-lc' : '-lic', nativeShellCommand(shellName)],
[shellName === 'sh' ? '-lc' : '-lic', nativeShellCommand()],
{
timeout: SHELL_ENV_TIMEOUT_MS,
timeout: 5000,
maxBuffer: MAX_SHELL_ENV_BYTES,
windowsHide: true,
env: childEnv,
@@ -284,61 +221,30 @@ async function queryNativeShellEnvironment(options = {}) {
source: Object.keys(loaded).some(key => key !== 'PATH') ? 'native-shell' : 'process',
}
} catch {
return { env: fallback, source: 'process', failed: true }
return { env: fallback, source: 'process' }
}
}
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).then(publicShellEnvironment)
if (!useCache) return queryNativeShellEnvironment(options)
const source = options.env || process.env
const platform = options.platform || process.platform
const home = options.home || os.homedir()
const shell = String(options.shell || source.SHELL || '')
const environmentHash = createHash('sha256')
.update(JSON.stringify(allowedShellEnvironment(source, platform))).digest('hex')
const key = [platform, home, shell, environmentHash].join('\u0000')
const key = [platform, home, shell, String(source.PATH || '')].join('\u0000')
const now = Date.now()
if (options.refresh !== true && shellEnvironmentCache?.key === key
&& shellEnvironmentCache.expiresAt > now) {
if (shellEnvironmentCache?.key === key && shellEnvironmentCache.expiresAt > now) {
return shellEnvironmentCache.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
const promise = queryNativeShellEnvironment(options)
shellEnvironmentCache = { key, expiresAt: now + SHELL_ENV_CACHE_TTL_MS, promise }
return promise
}
function nativeCredentialState(kind, options = {}) {
if (kind === 'workbuddy') return workBuddyCredentialState(options)
const home = options.home || os.homedir()
const env = options.env || process.env
const codexHome = env.CODEX_HOME || path.join(home, '.codex')
const claudeHome = env.CLAUDE_CONFIG_DIR || path.join(home, '.claude')
const piHome = env.PI_CODING_AGENT_DIR || path.join(home, '.pi', 'agent')
if (Object.keys(nativeCredentialKeyEnvironment(kind, env)).length) {
return {
state: 'ready',
@@ -347,13 +253,11 @@ function nativeCredentialState(kind, options = {}) {
}
const jsonFiles = {
zcode: [path.join(home, '.zcode', 'cli', 'config.json')],
codex: [path.join(codexHome, 'auth.json'), path.join(codexHome, 'config.json')],
codex: [path.join(home, '.codex', 'auth.json'), path.join(home, '.codex', 'config.json')],
hermes: [path.join(home, '.hermes', 'auth.json'), path.join(home, '.hermes', 'config.json')],
openclaw: [path.join(home, '.openclaw', 'openclaw.json')],
workbuddy: [path.join(home, '.workbuddy', 'models.json')],
pi: [
path.join(piHome, 'auth.json'),
path.join(piHome, 'models.json'),
path.join(home, '.pi', 'auth.json'),
path.join(home, '.pi', 'config.json'),
path.join(home, '.pi-agent', 'auth.json'),
@@ -361,16 +265,16 @@ function nativeCredentialState(kind, options = {}) {
],
kimi: [path.join(home, '.kimi-code', 'credentials', 'kimi-code.json')],
claude: [
path.join(claudeHome, '.credentials.json'),
path.join(claudeHome, 'settings.json'),
path.join(claudeHome, 'settings.local.json'),
path.join(home, '.claude', '.credentials.json'),
path.join(home, '.claude', 'settings.json'),
path.join(home, '.claude', 'settings.local.json'),
],
qwen: [path.join(home, '.qwen', 'oauth_creds.json'), path.join(home, '.qwen', 'settings.json')],
gemini: [
path.join(home, '.gemini', 'oauth_creds.json'),
path.join(home, '.gemini', 'settings.json'),
],
opencode: [path.join(env.XDG_DATA_HOME || path.join(home, '.local', 'share'), 'opencode', 'auth.json')],
opencode: [path.join(home, '.local', 'share', 'opencode', 'auth.json')],
opencodereview: [path.join(home, '.opencodereview', 'config.json')],
}[kind] || []
if (jsonFiles.some(jsonContainsCredential)) {
@@ -378,13 +282,14 @@ function nativeCredentialState(kind, options = {}) {
}
const textFiles = {
codex: [path.join(codexHome, '.env'), path.join(codexHome, 'config.toml')],
codex: [path.join(home, '.codex', '.env'), path.join(home, '.codex', 'config.toml')],
hermes: [
path.join(home, '.hermes', '.env'),
path.join(home, '.hermes', 'config.yaml'),
path.join(home, '.hermes', 'config.yml'),
],
openclaw: [path.join(home, '.openclaw', '.env')],
workbuddy: [path.join(home, '.workbuddy', '.env')],
pi: [
path.join(home, '.pi', '.env'),
path.join(home, '.pi', 'config.toml'),
@@ -397,7 +302,7 @@ function nativeCredentialState(kind, options = {}) {
path.join(home, '.kimi-code', '.env'),
],
mimo: [path.join(home, '.mimo', '.env'), path.join(home, '.mimocode', '.env')],
claude: [path.join(claudeHome, '.env')],
claude: [path.join(home, '.claude', '.env')],
qwen: [path.join(home, '.qwen', '.env')],
gemini: [path.join(home, '.gemini', '.env')],
opencode: [
@@ -417,14 +322,13 @@ function probeEnvironment(kind, options = {}) {
const source = options.env || process.env
const platform = options.platform || process.platform
const home = options.home || os.homedir()
const env = networkEnvironment(source, platform)
const env = {}
for (const key of [
'USER', 'LOGNAME', 'TMPDIR', 'TMP', 'TEMP',
'LANG', 'LANGUAGE', 'LC_ALL', 'LC_CTYPE',
'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'USERNAME',
'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT',
'APPDATA', 'LOCALAPPDATA',
'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME',
]) {
if (typeof source[key] === 'string' && source[key]) env[key] = source[key]
}
@@ -438,13 +342,6 @@ function probeEnvironment(kind, options = {}) {
function claudeAuthState(output) {
try {
const status = JSON.parse(String(output || '').trim())
// Cloud status recognizes a provider selection without checking SDK credentials.
if (status?.loggedIn === true && status.authMethod === 'third_party') {
return { state: 'unknown', source: 'unverified' }
}
if (status?.loggedIn === true && status.authMethod === 'api_key') {
return { state: 'ready', source: 'native-credential' }
}
if (status?.loggedIn === true) return { state: 'ready', source: 'native-auth-status' }
if (status?.loggedIn === false) return { state: 'missing', source: 'native-auth-status' }
} catch { /* keep the file-based result */ }
@@ -753,12 +650,8 @@ async function queryMimoAuthState(options = {}) {
}
async function resolveNativeCredentialState(kind, options = {}) {
if (kind === 'workbuddy') return workBuddyCredentialState(options)
const current = nativeCredentialState(kind, options)
if (kind === 'mimo') {
if (current.state === 'ready' || mimoConfiguredProvider(options)) {
return { state: 'ready', source: 'native-credential' }
}
return await queryMimoAuthState(options) || current
}
if (kind === 'openclaw') {
@@ -773,7 +666,8 @@ async function resolveNativeCredentialState(kind, options = {}) {
? status.credentialState
: { state: 'unknown', source: 'native-runtime-unavailable' }
}
if (kind !== 'claude' || !options.executable) return current
if (kind !== 'claude' || !options.executable
|| Object.keys(nativeCredentialKeyEnvironment(kind, options.env)).length) return current
const platform = options.platform || process.platform
const prepareCommandFn = options.prepareCommandFn || prepareCommand
+14 -10
View File
@@ -12,15 +12,6 @@ const MAX_ATTACHMENT_DISCARD_REQUEST = 32
function attachmentIdsFromSnapshot(snapshot, groupId = '') {
const ids = []
for (const task of snapshot?.deliveryTasks || []) {
if (groupId && task.groupId !== groupId) continue
for (const version of task.versions) {
for (const attachment of version.attachments) ids.push(attachment.id)
}
for (const attempt of task.attempts) {
for (const source of attempt.sources) if (source.attachmentId) ids.push(source.attachmentId)
}
}
for (const message of Array.isArray(snapshot?.messages) ? snapshot.messages : []) {
if (groupId && message?.groupId !== groupId) continue
for (const attachment of Array.isArray(message?.attachments) ? message.attachments : []) {
@@ -99,7 +90,7 @@ function normalizeAttachmentPickLimit(value) {
return Math.max(1, Math.min(MAX_ATTACHMENT_PICK_REQUEST, Math.floor(limit)))
}
function createAttachmentService({ getStore, getSnapshot, nativeImage, openPath }) {
function createAttachmentService({ getStore, getSnapshot, nativeImage, openPath, showSaveDialog }) {
function availableStore() {
const store = getStore()
if (!store) throw new Error('LOCAL_ATTACHMENT_STORAGE_UNAVAILABLE')
@@ -214,6 +205,18 @@ function createAttachmentService({ getStore, getSnapshot, nativeImage, openPath
return true
}
async function save(id) {
if (typeof showSaveDialog !== 'function') throw new Error('LOCAL_ATTACHMENT_SAVE_UNAVAILABLE')
const entry = availableStore().resolve([String(id || '')])[0]
const result = await showSaveDialog({
defaultPath: entry.name,
showOverwriteConfirmation: true,
})
if (result?.canceled || !result?.filePath) return { saved: false }
fs.writeFileSync(result.filePath, availableStore().readWithMetadata(entry.id).bytes)
return { saved: true }
}
return {
availableStore,
discardUnreferenced,
@@ -221,6 +224,7 @@ function createAttachmentService({ getStore, getSnapshot, nativeImage, openPath
importFiles,
normalizePickLimit: normalizeAttachmentPickLimit,
open,
save,
preview,
registerProtocol: protocol => protocol?.handle?.(MEDIA_SCHEME, request => mediaResponse(request)),
}
@@ -1,81 +0,0 @@
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 }
@@ -1,295 +0,0 @@
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,37 +533,6 @@ 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',
@@ -588,7 +557,6 @@ function collaborationPackageText(handoff, entries) {
}
module.exports = {
peerCollaborationText,
BLACKBOARD_ENTRY_VERSION,
COLLABORATION_STATE_VERSION,
HANDOFF_VERSION,
@@ -1,5 +1,4 @@
const crypto = require('node:crypto')
const { parseTaskDecision } = require('./task-decision.cjs')
const { normalizeContentBlobRef } = require('../attachments/content-blob-store.cjs')
const { canonicalJson } = require('./context-pack-records.cjs')
@@ -61,7 +60,7 @@ const MESSAGE_ID = /^message-[a-f0-9]{64}$/u
const EVIDENCE_ID = /^evidence-[a-f0-9]{64}$/u
const ORCHESTRATION_FIELDS = new Set([
'version', 'workflow', 'template', 'phase', 'discussionStyle', 'discussionWriterKind', 'batchId', 'round',
'version', 'workflow', 'template', 'phase', 'discussionStyle', 'batchId', 'round',
'currentKind', 'currentKinds', 'pendingKinds', 'activeKinds',
'successfulKinds', 'agreementKinds', 'attachmentRecipients',
'totalSuccesses', 'terminalFailureOccurred', 'collaboration', 'taskGraph',
@@ -830,7 +829,7 @@ function normalizeDeliveryState(input, targetKinds, index) {
if (!isRecord(input) || !exactFields(input, [
'recipientKind', 'sessionRefHash', 'sessionProvenanceHash',
'sourceAgentKind', 'sourcePhase', 'watermark', 'snapshotHash', 'operationId',
'packageHash', 'deliveryId', 'status', 'updatedAt', 'sourceMessages',
'packageHash', 'deliveryId', 'status', 'updatedAt',
])) fail('ORCHESTRATION_V4_DELIVERY_STATE_INVALID')
const recipientKind = cleanId(input.recipientKind)
const sourceAgentKind = cleanId(input.sourceAgentKind)
@@ -842,20 +841,6 @@ function normalizeDeliveryState(input, targetKinds, index) {
const packageHash = String(input.packageHash || '')
const deliveryId = cleanId(input.deliveryId)
const status = String(input.status || '')
let sourceMessages
if (hasOwn(input, 'sourceMessages')) {
if (!Array.isArray(input.sourceMessages) || !input.sourceMessages.length
|| input.sourceMessages.length > 100) fail('ORCHESTRATION_V4_DELIVERY_STATE_INVALID')
sourceMessages = input.sourceMessages.map(message => {
if (!isRecord(message) || !exactFields(message, ['id', 'hash'])
|| !cleanId(message.id) || cleanId(message.id) !== message.id
|| typeof message.hash !== 'string' || !SHA256.test(message.hash)) fail('ORCHESTRATION_V4_DELIVERY_STATE_INVALID')
return { id: message.id, hash: message.hash }
})
if (new Set(sourceMessages.map(message => message.id)).size !== sourceMessages.length) {
fail('ORCHESTRATION_V4_DELIVERY_STATE_INVALID')
}
}
if (!recipientKind || !sourceAgentKind || !targetKinds.includes(recipientKind)
|| !targetKinds.includes(sourceAgentKind) || !V4_PHASES.has(sourcePhase)
|| !Number.isSafeInteger(input.watermark) || input.watermark < 0
@@ -876,7 +861,6 @@ function normalizeDeliveryState(input, targetKinds, index) {
packageHash,
deliveryId,
status,
...(sourceMessages ? { sourceMessages } : {}),
updatedAt: safeTimestamp(input.updatedAt, index),
}
}
@@ -1601,12 +1585,11 @@ function validateV4Relations(orchestration, targetKinds) {
assignment.readOnly === false
))
const validWriterBinding = writerKind
? writableSlots.some(slot => slot.agentKind === writerKind)
&& writableSlots.length === writableAssignments.length
&& writableSlots.every(slot => writableAssignments.some(assignment => (
assignment.slotId === slot.slotId && assignment.agentKind === slot.agentKind
&& (writableSlots.length === 1 || assignment.role === 'writer')
)))
? writableSlots.length === 1
&& writableAssignments.length === 1
&& writableSlots[0].agentKind === writerKind
&& writableAssignments[0].agentKind === writerKind
&& writableAssignments[0].slotId === writableSlots[0].slotId
: writableSlots.length === 0 && writableAssignments.length === 0
if (!validWriterBinding) fail('ORCHESTRATION_V4_PERMISSION_INVALID')
}
@@ -1618,21 +1601,10 @@ function validateV4Relations(orchestration, targetKinds) {
|| orchestration.synthesisBinding?.writerKind
|| orchestration.coordinationPlan?.finalizerKind
|| orchestration.commitState.writerKind
|| orchestration.discussionWriterKind
const nativeWrites = Boolean(orchestration.discussionWriterKind
&& !orchestration.coordinationPlan && !orchestration.synthesisBinding
&& ['proposal', 'discussion'].includes(orchestration.phase))
if (!nativeWrites && (writableSlots.length > 1 || writableSlots.some(slot => (
if (writableSlots.length > 1 || writableSlots.some(slot => (
slot.agentKind !== effectiveWriterKind
|| !['discussion', 'synthesis', 'human-gate'].includes(orchestration.phase)
|| (orchestration.phase === 'discussion' && !orchestration.discussionWriterKind)
)))) fail('ORCHESTRATION_V4_PERMISSION_INVALID')
for (const assignment of ['proposal', 'discussion'].includes(orchestration.phase) ? orchestration.plan.assignments : []) {
const slot = orchestration.slots.find(item => item.slotId === assignment.slotId)
if (slot && assignment.readOnly !== (slot.permission !== 'workspace-write')) {
fail('ORCHESTRATION_V4_PERMISSION_INVALID')
}
}
|| !['synthesis', 'human-gate'].includes(orchestration.phase)
))) fail('ORCHESTRATION_V4_PERMISSION_INVALID')
}
const watermarkKeys = orchestration.deliveryWatermarks.map(item => `${item.agentKind}\u0000${item.phase}`)
if (new Set(watermarkKeys).size !== watermarkKeys.length) {
@@ -1927,15 +1899,6 @@ function parseOrchestrationV4(input, options = {}) {
if (coordinationPlan && (workflow !== 'auto' || template !== 'discussion')) {
fail('ORCHESTRATION_V4_COORDINATION_PLAN_INVALID')
}
const discussionWriterKind = hasOwn(input, 'discussionWriterKind') ? cleanId(input.discussionWriterKind) : ''
if (hasOwn(input, 'discussionWriterKind') && (!discussionWriterKind
|| input.discussionWriterKind !== discussionWriterKind
|| !effectiveKinds.includes(discussionWriterKind)
|| workflow !== 'auto' || template !== 'discussion'
|| !['proposal', 'discussion', 'stopped', 'failed'].includes(phase)
|| synthesisBinding || coordinationPlan || commitState.writerKind)) {
fail('ORCHESTRATION_V4_PERMISSION_INVALID')
}
if (coordinationPlan) {
validateCoordinationAgreement(
coordinationPlan, slots, challengeBindings, participantKinds, snapshotHash,
@@ -2063,7 +2026,6 @@ function parseOrchestrationV4(input, options = {}) {
template,
phase,
...(discussionStyle ? { discussionStyle } : {}),
...(discussionWriterKind ? { discussionWriterKind } : {}),
batchId,
currentKinds,
activeKinds,
@@ -2208,7 +2170,7 @@ function parseCollaborationControlBlock(input, options = {}) {
'claims', 'findings', 'refs', 'writer', 'writerKind', 'batchId', 'entryIds',
'capabilities', 'intendedWork', 'deliverables', 'dependencies',
'proposedAssignments', 'finalizerKind', 'verifierKinds', 'supportedPlanHash',
'agreeToPlan', 'workItemId', 'taskDecision',
'agreeToPlan', 'workItemId',
]) || input.version !== 1) {
fail('COLLABORATION_CONTROL_BLOCK_INVALID')
}
@@ -2218,8 +2180,6 @@ function parseCollaborationControlBlock(input, options = {}) {
fail('COLLABORATION_CONTROL_BLOCK_INVALID')
}
const summary = safeText(input.summary, MAX_V4_SUMMARY_CHARS, true)
const taskDecision = input.taskDecision == null ? null : parseTaskDecision(input.taskDecision)
if (taskDecision && phase !== 'discussion') fail('COLLABORATION_CONTROL_BLOCK_INVALID')
const boundedList = (value, limit = MAX_V4_REFS, textLimit = MAX_V4_TEXT_CHARS) => {
if (value == null) return []
if (!Array.isArray(value) || value.length > limit) fail('COLLABORATION_CONTROL_BLOCK_INVALID')
@@ -2350,7 +2310,6 @@ function parseCollaborationControlBlock(input, options = {}) {
status,
summary,
conclusion,
...(taskDecision ? { taskDecision } : {}),
artifactIds,
evidenceIds,
findingIds,
@@ -2389,7 +2348,6 @@ function collaborationChars(block) {
...(Array.isArray(block.intendedWork) ? block.intendedWork : []),
...(Array.isArray(block.deliverables) ? block.deliverables : []),
...(Array.isArray(block.dependencies) ? block.dependencies : []),
...(block.taskDecision ? [block.taskDecision.reason, ...block.taskDecision.deliverables] : []),
...(Array.isArray(block.proposedAssignments)
? block.proposedAssignments.flatMap(item => [item.objective, item.expectedOutput])
: []),
@@ -2598,7 +2556,6 @@ function createCollaborationReceipt(input, options = {}) {
...(input.supportedPlanHash ? { supportedPlanHash: input.supportedPlanHash } : {}),
...(typeof input.agreeToPlan === 'boolean' ? { agreeToPlan: input.agreeToPlan } : {}),
...(input.workItemId ? { workItemId: input.workItemId } : {}),
...(input.taskDecision ? { taskDecision: input.taskDecision } : {}),
deliveryWatermark: input.deliveryWatermark || 0,
snapshotHash: input.snapshotHash,
...(typeof input.writer === 'boolean' ? { writer: input.writer } : {}),
@@ -1,92 +0,0 @@
const { redactSecrets } = require('../security/secret-redaction.cjs')
function parseTaskDecision(input) {
const invalid = () => { throw new Error('LOCAL_RUN_TASK_DECISION_INVALID') }
if (!input || typeof input !== 'object' || Array.isArray(input)
|| Object.keys(input).some(key => !['status', 'reason', 'deliverables', 'handoffTo', 'nextKinds', 'reviewedVersion'].includes(key))
|| !['completed', 'continue', 'blocked', 'needs-human'].includes(input.status)) invalid()
const text = (value, limit) => {
if (typeof value !== 'string' || !value.trim() || value.length > limit
|| /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)) invalid()
return redactSecrets(value.trim())
}
const reason = text(input.reason, 1600)
if (!Array.isArray(input.deliverables) || input.deliverables.length > 16) invalid()
const deliverables = input.deliverables.map(value => text(value, 800))
if (input.status === 'completed' && !deliverables.length) invalid()
if (input.handoffTo != null && (input.status !== 'continue'
|| typeof input.handoffTo !== 'string'
|| !/^[a-z0-9][a-z0-9-]{0,79}$/.test(input.handoffTo))) invalid()
if (input.nextKinds != null && (!Array.isArray(input.nextKinds)
|| input.nextKinds.length > 32
|| input.nextKinds.some(kind => typeof kind !== 'string'
|| !/^[a-z0-9][a-z0-9-]{0,79}$/i.test(kind))
|| (input.nextKinds.length > 0 && input.status !== 'continue'))) invalid()
const nextKinds = input.nextKinds?.map(kind => kind.toLowerCase())
if (nextKinds && new Set(nextKinds).size !== nextKinds.length) invalid()
if (input.reviewedVersion != null && (typeof input.reviewedVersion !== 'string'
|| !/^[a-f0-9]{64}$/.test(input.reviewedVersion))) invalid()
return {
status: input.status, reason, deliverables,
...(input.handoffTo != null ? { handoffTo: input.handoffTo } : {}),
...(nextKinds ? { nextKinds } : {}),
...(input.reviewedVersion != null ? { reviewedVersion: input.reviewedVersion } : {}),
}
}
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) : [] }
: {}),
...(input.reviewedVersion != null ? { reviewedVersion: input.reviewedVersion } : {}),
}
}
function parseLenientTaskDecision(input, options = {}) {
return parseTaskDecision(normalizeTaskDecision(input, options))
}
module.exports = { normalizeTaskDecision, parseLenientTaskDecision, parseTaskDecision }
+1 -3
View File
@@ -208,11 +208,9 @@ class HumanGateCoordinator {
status,
optionId: option.optionId,
actorId: input.actorId || 'local-user',
decidedAt: input.decidedAt || current.decision?.decidedAt || this.now(),
decidedAt: input.decidedAt || this.now(),
...(response ? { response } : {}),
})
// Recovery owns recorded decisions; repeated submissions must not dispatch them again.
if (current.status !== 'pending') return publicHumanGate(record)
this.clearExpiry(record.gateId)
try {
this.onResumed(record, record.decision)
+40 -63
View File
@@ -1,8 +1,6 @@
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')
@@ -27,7 +25,6 @@ const {
shutdownAcpSessionRuntime,
} = require('./agents/cli/cli-adapters.cjs')
const { AgentInstaller } = require('./agents/installer/agent-installer.cjs')
const { networkEnvironment } = require('./agents/cli/cli-network-environment.cjs')
const { AgentConnectorInstanceStore } = require('./agents/connectors/agent-connector-instance-store.cjs')
const { LocalAgentConnectors } = require('./agents/connectors/agent-connector-local.cjs')
const { AgentConnectorPackageStore } = require('./agents/connectors/agent-connector-package-store.cjs')
@@ -51,7 +48,6 @@ const {
} = require('./agents/local-agent-readiness.cjs')
const { LocalWorkspace } = require('./workspace/local-workspace.cjs')
const { registerDesktopIpc } = require('./shell/main-ipc.cjs')
const { workBuddyEnvironment, workBuddySettings } = require('./agents/cli/workbuddy-runtime.cjs')
const {
isAllowedExternalUrl,
isAllowedLocalNavigation: isAllowedLocalNavigationForPaths,
@@ -72,37 +68,26 @@ const {
resolveKnowledgeBaseSources,
} = require('./knowledge/local-knowledge-base.cjs')
const { LocalKnowledgeConnectors } = require('./knowledge/local-knowledge-connectors.cjs')
const { nativeOpenClawOptions } = require('./agents/cli/openclaw-runtime.cjs')
const { ProviderStore } = require('./providers/provider-store.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', 'zcode', 'pi', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen',
'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
@@ -148,6 +133,7 @@ const attachments = createAttachmentService({
getSnapshot: () => workspace?.snapshot(),
nativeImage: electron.nativeImage,
openPath: filename => shell.openPath(filename),
showSaveDialog: options => dialog.showSaveDialog(mainWindow, options),
})
protocol?.registerSchemesAsPrivileged?.([{
@@ -239,9 +225,21 @@ function openExternalUrl(value) {
return true
}
async function nativeShellEnvironment(options = {}) {
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() {
const home = app.getPath('home')
const resolved = await resolveNativeShellEnvironment({ home, refresh: options.refresh === true })
const resolved = await resolveNativeShellEnvironment({ home })
return {
...resolved,
env: { ...process.env, ...resolved.env },
@@ -343,34 +341,28 @@ async function runCoreAgent(agent, prompt, workdir, options = {}) {
const connectorCredentialIsolation = options.connectorCredentialIsolation === true
const runOptions = { ...options }
delete runOptions.connectorCredentialIsolation
const status = providerStore.status(agent.kind)
const shellEnvironment = connectorCredentialIsolation ? null : await nativeShellEnvironment()
// OpenClaw runs through its isolated native runtime scope; every other Agent
// CLI uses its own native credentials.
const nativeRuntime = !connectorCredentialIsolation && agent.kind === 'openclaw'
const nativeRuntime = !connectorCredentialIsolation
&& agent.kind === 'openclaw' && !status.configured
? await resolveNativeOpenClawRuntime({
executable: agent.executable,
env: shellEnvironment.env,
home: shellEnvironment.home,
})
: null
const injected = connectorCredentialIsolation || agent.kind !== 'openclaw' || !nativeRuntime
const injected = connectorCredentialIsolation
? {}
: nativeOpenClawOptions({
storageRoot: app.getPath('userData'),
: providerOptions(agent.kind, {
...runOptions,
workdir,
sessionRef: runOptions.sessionRef,
allowWrite: runOptions.sandbox === 'workspace-write',
runtime: nativeRuntime,
openclawVersion: agent.version,
storageRoot: app.getPath('userData'),
nativeRuntime,
})
const nativeEnv = connectorCredentialIsolation || agent.kind === 'openclaw'
? {}
: {
...nativeCredentialEnvironment(agent.kind, shellEnvironment.env),
...(agent.kind === 'workbuddy' ? workBuddyEnvironment({
env: shellEnvironment.env, home: shellEnvironment.home,
modelId: workspace?.state.agentPreferences.workbuddy?.modelId,
}) : {}),
...(shellEnvironment.env.PATH ? { PATH: shellEnvironment.env.PATH } : {}),
}
const callerEnv = connectorCredentialIsolation || agent.kind !== 'openclaw'
@@ -379,10 +371,7 @@ async function runCoreAgent(agent, prompt, workdir, options = {}) {
return runAgent(agent, prompt, workdir, {
...runOptions,
...injected,
env: {
...networkEnvironment(shellEnvironment?.env),
...nativeEnv, ...callerEnv, ...injected.env,
},
env: { ...nativeEnv, ...callerEnv, ...injected.env },
})
}
@@ -551,10 +540,10 @@ function createWorkspace() {
env: shellEnvironment.env,
home: shellEnvironment.home,
credentialSource: shellEnvironment.source,
...(kind === 'workbuddy' ? { modelId: workspace?.state.agentPreferences.workbuddy?.modelId } : {}),
})
},
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 = {}) => {
@@ -633,7 +622,6 @@ async function refreshLocalAgentState() {
if (!target) return null
const task = localAgentRefreshQueue.then(async () => {
if (workspace !== target) return workspace?.snapshot() || null
await nativeShellEnvironment({ refresh: true })
const snapshot = await target.refreshAgents()
if (workspace !== target) return workspace?.snapshot() || null
return snapshot
@@ -702,14 +690,6 @@ 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
})
@@ -749,10 +729,6 @@ function registerIpc() {
getCloudAgentBridges: () => cloudAgentBridges,
getMainWindow: () => mainWindow,
getWorkspace: () => workspace,
getWorkBuddySettings: async () => {
const shell = await nativeShellEnvironment()
return workBuddySettings({ ...shell, modelId: workspace?.state.agentPreferences.workbuddy?.modelId })
},
installer,
isShutdownStarted: () => shutdownStarted,
knowledgeBaseStore,
@@ -763,6 +739,8 @@ function registerIpc() {
openExternalUrl,
getOutcomeStore: () => workspace?.outcomeStore,
getSkillTrustStore: () => skillTrustStore,
providerStore,
providerAgentKind,
refreshLocalAgentState,
registerTrustedHandle,
skillCatalog,
@@ -795,12 +773,11 @@ if (!hasSingleInstanceLock) {
storagePath: path.join(app.getPath('userData'), 'meldwork-cloud-agent-bridges.json'),
fetch: globalThis.fetch,
})
// 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 */ }
providerStore = new ProviderStore({
storagePath: path.join(app.getPath('userData'), 'meldwork-provider.json'),
safeStorage: lazySafeStorage,
allowedKinds: [...LOCAL_AGENT_KINDS],
})
knowledgeBaseStore = new KnowledgeBaseStore({
storagePath: path.join(app.getPath('userData'), 'meldwork-knowledge-base.json'),
})
@@ -1,19 +0,0 @@
const { cleanText } = require('../workspace/local-workspace-contracts.cjs')
const MEDIA_TERMS = Object.freeze([
['video', /(?:视频|影片|短片|video|film)/i],
['audio', /(?:音频|语音|配音|音乐|audio|voice|speech|music)/i],
['image', /(?:图片|图像|照片|插画|海报|image|photo|illustration|poster)/i],
])
const REQUEST_TERM = /(?:请|帮我|给我|生成|创建|制作|画(?:一|个)?|做(?:一|个)?|create|generate|make|draw|render|produce)/i
const QUESTION_PREFIX = /^(?:能否|可以(?:吗)?|是否|怎么|如何|为什么|解释|说明|介绍|分析|can\s+(?:you|codex)|how\s+(?:do|to)|why\b)/i
function mediaGenerationRequest(value) {
const prompt = cleanText(value, 12000)
if (!prompt || QUESTION_PREFIX.test(prompt) || /(?:吗|?|\?)\s*$/.test(prompt)
|| !REQUEST_TERM.test(prompt)) return null
const match = MEDIA_TERMS.find(([, expression]) => expression.test(prompt))
return match ? { type: match[0], prompt } : null
}
module.exports = { mediaGenerationRequest }
@@ -1,491 +0,0 @@
const crypto = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const OUTPUT_DIRECTORY = '.meldwork-output'
const MAX_MEDIA_BYTES = 128 * 1024 * 1024
const POLL_INTERVAL_MS = 10_000
const PROVIDER_ATTEMPTS = 3
const RETRYABLE_PROVIDER_STATUSES = new Set([429, 502, 503, 504])
const SUPPORTED_TYPES = new Set(['image', 'audio', 'video'])
function mediaError(code, detail = '') {
const error = new Error(code)
error.code = code
if (detail) error.diagnostic = detail.slice(0, 800)
return error
}
function requestUrl(baseUrl, suffix) {
const base = String(baseUrl || '').replace(/\/+$/, '')
if (!base) throw mediaError('MEDIA_GENERATION_PROVIDER_UNAVAILABLE')
return `${base}${suffix}`
}
function responseExtension(type, contentType = '', sourceUrl = '') {
const normalized = String(contentType || '').toLowerCase().split(';')[0]
const byContentType = {
'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif',
'audio/mpeg': 'mp3', 'audio/wav': 'wav', 'audio/mp4': 'm4a',
'video/mp4': 'mp4', 'video/quicktime': 'mov', 'video/webm': 'webm',
}
if (byContentType[normalized]) return byContentType[normalized]
try {
const extension = path.extname(new URL(sourceUrl).pathname).slice(1).toLowerCase()
if (['png', 'jpg', 'jpeg', 'webp', 'gif', 'mp3', 'wav', 'm4a', 'mp4', 'mov', 'webm']
.includes(extension)) return extension === 'jpeg' ? 'jpg' : extension
} catch { /* no usable source URL */ }
return { image: 'png', audio: 'mp3', video: 'mp4' }[type]
}
function base64Bytes(value) {
if (typeof value !== 'string' || !value || value.length > Math.ceil(MAX_MEDIA_BYTES * 1.4)) {
throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
}
const normalized = value.replace(/\s+/g, '')
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(normalized)) {
throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
}
const bytes = Buffer.from(normalized, 'base64')
if (!bytes.length || bytes.length > MAX_MEDIA_BYTES) {
throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
}
return bytes
}
function responsePayload(payload) {
const item = Array.isArray(payload?.data) ? payload.data[0] : payload
if (!item || typeof item !== 'object') return null
if (typeof item.b64_json === 'string') return { bytes: base64Bytes(item.b64_json) }
if (typeof item.base64 === 'string') return { bytes: base64Bytes(item.base64) }
if (typeof item.url === 'string') return { url: item.url }
if (typeof payload?.url === 'string') return { url: payload.url }
return null
}
function firstValue(payload, keys) {
if (Array.isArray(payload)) {
for (const item of payload) {
const found = firstValue(item, keys)
if (found != null && found !== '') return found
}
return null
}
if (!payload || typeof payload !== 'object') return null
for (const key of keys) {
if (payload[key] != null && payload[key] !== '') return payload[key]
}
for (const value of Object.values(payload)) {
const found = firstValue(value, keys)
if (found != null && found !== '') return found
}
return null
}
function nestedResponsePayload(payload) {
const direct = responsePayload(payload)
if (direct) return direct
const encoded = firstValue(payload, ['b64_json', 'base64', 'video_base64'])
if (typeof encoded === 'string') return { bytes: base64Bytes(encoded) }
const url = firstValue(payload, ['url', 'download_url', 'downloadUrl', 'video_url', 'videoUrl'])
return typeof url === 'string' ? { url } : null
}
function outputDirectory(workdir) {
if (typeof workdir !== 'string' || !path.isAbsolute(workdir)) {
throw mediaError('MEDIA_GENERATION_WORKDIR_INVALID')
}
let realWorkdir
try { realWorkdir = fs.realpathSync(workdir) } catch { throw mediaError('MEDIA_GENERATION_WORKDIR_INVALID') }
const directory = path.join(realWorkdir, OUTPUT_DIRECTORY)
try {
fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
const stat = fs.lstatSync(directory)
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error('unsafe output directory')
const realDirectory = fs.realpathSync(directory)
const relative = path.relative(realWorkdir, realDirectory)
if (!relative || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
throw new Error('outside workdir')
}
return realDirectory
} catch {
throw mediaError('MEDIA_GENERATION_OUTPUT_UNAVAILABLE')
}
}
function officialModel(type, provider) {
let host = ''
try { host = new URL(provider.baseUrl).hostname.toLowerCase() } catch { /* validated upstream */ }
if (host === 'api.openai.com') {
return { image: 'gpt-image-1', audio: 'gpt-4o-mini-tts', video: 'sora-2' }[type]
}
if (host === 'hub.zgci.org') {
return {
image: 'qwen-image',
audio: 'cosy-voice',
video: 'minimax-h3',
}[type]
}
return provider.model
}
function h3Provider(provider) {
let host = ''
try { host = new URL(provider.baseUrl).hostname.toLowerCase() } catch { return false }
return host === 'helper.ihainan.me' && /(?:^|[-_/])(?:minimax-)?h3(?:$|[-_/])/i.test(provider.model)
}
function zgciProvider(provider) {
try { return new URL(provider.baseUrl).hostname.toLowerCase() === 'hub.zgci.org' } catch { return false }
}
function providerModelUnavailable(error) {
const diagnostic = String(error?.diagnostic || '').toLowerCase()
return diagnostic.includes('model_not_found') || diagnostic.includes('no available channel for model')
}
function providerRetryExhausted(error) {
const match = /^MEDIA_GENERATION_HTTP_(\d+)$/.exec(String(error?.code || error?.message || ''))
return RETRYABLE_PROVIDER_STATUSES.has(Number(match?.[1]))
}
function sleep(delayMs, signal) {
if (signal?.aborted) return Promise.reject(mediaError('LOCAL_AGENT_EXECUTION_STOPPED'))
return new Promise((resolve, reject) => {
const done = () => {
signal?.removeEventListener('abort', abort)
resolve()
}
const timer = setTimeout(done, delayMs)
const abort = () => {
clearTimeout(timer)
reject(mediaError('LOCAL_AGENT_EXECUTION_STOPPED'))
}
signal?.addEventListener('abort', abort, { once: true })
})
}
async function boundedResponseBytes(response, maximum = MAX_MEDIA_BYTES) {
const advertisedLength = Number(response.headers?.get?.('content-length'))
if (Number.isFinite(advertisedLength) && advertisedLength > maximum) {
throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
}
const reader = response.body?.getReader?.()
if (!reader) {
const bytes = Buffer.from(await response.arrayBuffer())
if (bytes.length > maximum) throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
return bytes
}
const chunks = []
let total = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = Buffer.from(value)
total += chunk.length
if (total > maximum) throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
chunks.push(chunk)
}
} finally {
reader.releaseLock?.()
}
return Buffer.concat(chunks, total)
}
class MediaGenerationRuntime {
constructor({
getProvider,
fetchFn = globalThis.fetch,
createId = crypto.randomUUID,
pollIntervalMs = POLL_INTERVAL_MS,
} = {}) {
if (typeof getProvider !== 'function' || typeof fetchFn !== 'function' || typeof createId !== 'function') {
throw mediaError('MEDIA_GENERATION_RUNTIME_INVALID')
}
this.getProvider = getProvider
this.fetch = fetchFn
this.createId = createId
this.pollIntervalMs = Math.max(0, Number(pollIntervalMs) || 0)
}
emit(onEvent, type, status, id, summary = '') {
try {
onEvent?.({
id: `media-${id}`,
type: status === 'running' ? 'tool_start' : 'tool_result_summary',
status,
title: `${type}_generation`,
summary,
})
} catch { /* trace delivery is best effort */ }
}
async post(provider, suffix, body, signal) {
for (let attempt = 1; attempt <= PROVIDER_ATTEMPTS; attempt += 1) {
let response
try {
response = await this.fetch(requestUrl(provider.baseUrl, suffix), {
method: 'POST',
headers: {
Authorization: h3Provider(provider)
? `Basic ${Buffer.from(provider.apiKey, 'utf8').toString('base64')}`
: `Bearer ${provider.apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json, image/*, audio/*, video/*',
},
body: JSON.stringify(body),
signal,
})
} catch (error) {
if (signal?.aborted) throw mediaError('LOCAL_AGENT_EXECUTION_STOPPED')
throw mediaError('MEDIA_GENERATION_NETWORK_FAILED', error?.message || '')
}
if (response?.ok) return response
const status = Number(response?.status) || 0
let detail = ''
try { detail = await response.text() } catch { /* no response detail */ }
const error = mediaError(`MEDIA_GENERATION_HTTP_${status}`, detail)
if (providerModelUnavailable(error)
|| !RETRYABLE_PROVIDER_STATUSES.has(status)
|| attempt === PROVIDER_ATTEMPTS) throw error
await sleep(Math.min(this.pollIntervalMs, 1_000) * attempt, signal)
}
throw mediaError('MEDIA_GENERATION_NETWORK_FAILED')
}
async getJson(provider, suffix, signal) {
let response
try {
response = await this.fetch(requestUrl(provider.baseUrl, suffix), {
headers: {
Authorization: h3Provider(provider)
? `Basic ${Buffer.from(provider.apiKey, 'utf8').toString('base64')}`
: `Bearer ${provider.apiKey}`,
Accept: 'application/json',
},
signal,
})
} catch (error) {
if (signal?.aborted) throw mediaError('LOCAL_AGENT_EXECUTION_STOPPED')
throw mediaError('MEDIA_GENERATION_NETWORK_FAILED', error?.message || '')
}
if (!response?.ok) {
let detail = ''
try { detail = await response.text() } catch { /* no response detail */ }
throw mediaError(`MEDIA_GENERATION_HTTP_${Number(response?.status) || 0}`, detail)
}
try { return await response.json() } catch { throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE') }
}
async download(url, signal, provider = null) {
let parsed
try { parsed = new URL(url) } catch { throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE') }
const loopback = ['127.0.0.1', 'localhost', '[::1]'].includes(parsed.hostname)
if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback)) {
throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
}
let response
const providerHost = (() => {
try { return new URL(provider?.baseUrl || '').hostname.toLowerCase() } catch { return '' }
})()
const headers = providerHost && parsed.hostname.toLowerCase() === providerHost
? { Authorization: `Bearer ${provider.apiKey}` }
: undefined
try { response = await this.fetch(parsed, { headers, signal }) } catch (error) {
if (signal?.aborted) throw mediaError('LOCAL_AGENT_EXECUTION_STOPPED')
throw mediaError('MEDIA_GENERATION_DOWNLOAD_FAILED', error?.message || '')
}
if (!response?.ok) throw mediaError(`MEDIA_GENERATION_DOWNLOAD_HTTP_${Number(response.status) || 0}`)
const bytes = await boundedResponseBytes(response)
if (!bytes.length || bytes.length > MAX_MEDIA_BYTES) throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
return { bytes, contentType: response.headers?.get?.('content-type') || '', sourceUrl: parsed.href }
}
async videoResponse(provider, body, signal, submitSuffix = '/videos') {
let response = await this.post(provider, submitSuffix, body, signal)
const contentType = response.headers?.get?.('content-type') || ''
if (!/application\/json/i.test(contentType)) {
const bytes = await boundedResponseBytes(response)
return { bytes, contentType }
}
let payload
try { payload = await response.json() } catch { throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE') }
let output = responsePayload(payload)
while (!output && payload?.id && ['queued', 'in_progress', 'processing'].includes(payload.status)) {
await sleep(this.pollIntervalMs, signal)
payload = await this.getJson(provider, `/videos/${encodeURIComponent(payload.id)}`, signal)
if (payload?.status === 'failed') throw mediaError('MEDIA_GENERATION_FAILED')
output = responsePayload(payload)
}
if (!output && payload?.id && ['completed', 'succeeded', 'success'].includes(payload.status)) {
output = { url: requestUrl(provider.baseUrl, `/videos/${encodeURIComponent(payload.id)}/content`) }
}
if (!output) throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
return output
}
async h3VideoResponse(provider, body, signal, onEvent, id) {
const submitted = await this.post(provider, '/v1/videogen', body, signal)
let task
try { task = await submitted.json() } catch { throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE') }
const taskId = typeof task?.task_id === 'string' ? task.task_id : ''
if (!taskId) throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
let status = String(task.status || 'queued')
while (['queued', 'in_progress'].includes(status)) {
const progress = Number(task.progress)
const progressText = Number.isFinite(progress) ? ` ${Math.max(0, Math.min(100, progress))}%` : ''
this.emit(onEvent, 'video', 'running', id, `Generating video: ${status}${progressText}`)
await sleep(this.pollIntervalMs, signal)
task = await this.getJson(provider, `/v1/videogen/${encodeURIComponent(taskId)}`, signal)
status = String(task?.status || '')
}
if (status !== 'completed') throw mediaError('MEDIA_GENERATION_FAILED')
let response
try {
response = await this.fetch(requestUrl(provider.baseUrl, `/v1/videogen/${encodeURIComponent(taskId)}/content`), {
headers: {
Authorization: `Basic ${Buffer.from(provider.apiKey, 'utf8').toString('base64')}`,
Accept: 'video/mp4',
},
signal,
})
} catch (error) {
if (signal?.aborted) throw mediaError('LOCAL_AGENT_EXECUTION_STOPPED')
throw mediaError('MEDIA_GENERATION_DOWNLOAD_FAILED', error?.message || '')
}
if (!response?.ok) throw mediaError(`MEDIA_GENERATION_DOWNLOAD_HTTP_${Number(response?.status) || 0}`)
return {
bytes: await boundedResponseBytes(response),
contentType: response.headers?.get?.('content-type') || 'video/mp4',
}
}
async zgciH3VideoResponse(provider, prompt, signal, onEvent, id) {
const submitted = await this.post(provider, '/video_generation', {
model: 'H3',
prompt,
duration_seconds: 8,
size: '1280x720',
fps: 24,
}, signal)
let task
try { task = await submitted.json() } catch { throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE') }
const taskId = firstValue(task, ['task_id', 'taskId', 'id'])
if (typeof taskId !== 'string' || !taskId) throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
const successStates = new Set(['success', 'succeeded', 'completed', 'complete', 'done'])
const failureStates = new Set(['failed', 'error', 'cancelled', 'canceled'])
let status = String(firstValue(task, ['status', 'state', 'task_status', 'taskStatus']) || '').toLowerCase()
while (!successStates.has(status)) {
if (failureStates.has(status)) throw mediaError('MEDIA_GENERATION_FAILED')
this.emit(onEvent, 'video', 'running', id, status ? `Generating video: ${status}` : 'Generating video')
await sleep(this.pollIntervalMs, signal)
const response = await this.post(provider, '/query/video_generation', { task_id: taskId }, signal)
try { task = await response.json() } catch { throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE') }
status = String(firstValue(task, ['status', 'state', 'task_status', 'taskStatus']) || '').toLowerCase()
}
const direct = nestedResponsePayload(task)
if (direct) return direct
const fileId = firstValue(task, ['file_id', 'fileId', 'video_file_id', 'videoFileId'])
if (typeof fileId !== 'string' || !fileId) throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
const retrieved = await this.post(provider, '/files/retrieve', { file_id: fileId }, signal)
const contentType = retrieved.headers?.get?.('content-type') || ''
if (!/application\/json/i.test(contentType)) {
return { bytes: await boundedResponseBytes(retrieved), contentType }
}
let payload
try { payload = await retrieved.json() } catch { throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE') }
const output = nestedResponsePayload(payload)
if (!output) throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
return output
}
async generate({ kind, request, workdir, signal, onEvent } = {}) {
const type = String(request?.type || '')
const prompt = String(request?.prompt || '').trim()
if (!kind || !SUPPORTED_TYPES.has(type) || !prompt || prompt.length > 12000) {
throw mediaError('MEDIA_GENERATION_REQUEST_INVALID')
}
const id = String(this.createId()).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 48) || 'media'
this.emit(onEvent, type, 'running', id, `Generating ${type}`)
try {
let output
let provider
let providerFallbackError = null
const excludedKinds = []
while (!output) {
try {
provider = await this.getProvider(kind, type, excludedKinds)
} catch (error) {
if (providerModelUnavailable(providerFallbackError)) {
throw mediaError('MEDIA_GENERATION_MODEL_UNAVAILABLE', providerFallbackError.diagnostic)
}
if (providerFallbackError) throw providerFallbackError
throw error
}
if (!provider?.apiKey || !provider?.baseUrl || !provider?.model) {
throw mediaError('MEDIA_GENERATION_PROVIDER_UNAVAILABLE')
}
const mediaModel = officialModel(type, provider)
const body = type === 'image'
? { model: mediaModel, prompt, response_format: 'b64_json' }
: type === 'audio'
? zgciProvider(provider)
? {
model: mediaModel,
input: prompt,
voice: 'mm_Southern_Young_Man',
response_format: 'wav',
}
: { model: mediaModel, input: prompt, voice: 'alloy', response_format: 'mp3' }
: { model: mediaModel, prompt, seconds: '4', size: '1280x720' }
try {
if (type === 'video') {
output = zgciProvider(provider)
? await this.videoResponse(provider, body, signal, '/video/generations')
: h3Provider(provider)
? await this.h3VideoResponse(provider, { prompt, seconds: 4, steps: 20, size: '1280x720' }, signal, onEvent, id)
: await this.videoResponse(provider, body, signal)
}
else {
const suffix = type === 'image' ? '/images/generations' : '/audio/speech'
const response = await this.post(provider, suffix, body, signal)
const contentType = response.headers?.get?.('content-type') || ''
if (/application\/json/i.test(contentType)) {
let payload
try { payload = await response.json() } catch { throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE') }
output = responsePayload(payload)
if (!output) throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
} else {
output = { bytes: await boundedResponseBytes(response), contentType }
}
}
} catch (error) {
if ((!providerModelUnavailable(error) && !providerRetryExhausted(error)) || !provider.sourceKind
|| excludedKinds.includes(provider.sourceKind)) throw error
providerFallbackError = error
excludedKinds.push(provider.sourceKind)
}
}
const downloaded = output.url ? await this.download(output.url, signal, provider) : output
const bytes = Buffer.from(downloaded.bytes || [])
if (!bytes.length || bytes.length > MAX_MEDIA_BYTES) throw mediaError('MEDIA_GENERATION_INVALID_RESPONSE')
const extension = responseExtension(type, downloaded.contentType, downloaded.sourceUrl || output.url)
const filename = `generated-${type}-${id}.${extension}`
const destination = path.join(outputDirectory(workdir), filename)
fs.writeFileSync(destination, bytes, { flag: 'wx', mode: 0o600 })
this.emit(onEvent, type, 'completed', id, filename)
return { filename, type }
} catch (error) {
this.emit(onEvent, type, 'failed', id, String(error?.code || error?.message || 'generation failed'))
throw error
}
}
}
module.exports = { MediaGenerationRuntime, mediaError }
@@ -1,68 +0,0 @@
function providerHost(baseUrl) {
try { return new URL(baseUrl).hostname.toLowerCase() } catch { return '' }
}
function mediaProviderScore(type, requestedKind, candidateKind, status = {}) {
const host = providerHost(status.baseUrl)
const model = String(status.model || '').toLowerCase()
let score = candidateKind === requestedKind ? 20 : 0
if (candidateKind === 'codex') score += 5
if (host === 'api.openai.com') score += 80
if (host === 'hub.zgci.org') score += type === 'video' ? 120 : 70
if (host === 'helper.ihainan.me' && /h3/.test(model)) score += type === 'video' ? 110 : -100
if (type === 'video' && /(h3|sora|video|hailuo|minimax)/.test(model)) score += 30
if (type === 'image' && /(image|dall-e|flux|qwen)/.test(model)) score += 30
if (type === 'audio' && /(audio|speech|tts|voice)/.test(model)) score += 30
return score
}
function resolveMediaProvider({
requestedKind, type, kinds, statusFor, credentialsFor, excludedKinds = [],
fallbackProviders = [],
}) {
const excluded = new Set(excludedKinds)
const configuredFallbacks = (Array.isArray(fallbackProviders) ? fallbackProviders : [])
.map((provider) => {
const kind = String(provider?.kind || '').trim()
if (!kind || excluded.has(kind)) return null
const status = provider?.status
const credentials = provider?.credentials
return status?.configured && credentials?.OPENAI_API_KEY
? { kind, status, credentials, fallback: true }
: null
})
.filter(Boolean)
const candidates = [
...[...new Set([requestedKind, 'codex', ...(kinds || [])])]
.filter(kind => kind && !excluded.has(kind))
.map((kind) => {
try {
const status = statusFor(kind)
return status?.configured ? { kind, status } : null
} catch {
return null
}
})
.filter(Boolean),
...configuredFallbacks,
]
.sort((left, right) => (
mediaProviderScore(type, requestedKind, right.kind, right.status)
- mediaProviderScore(type, requestedKind, left.kind, left.status)
))
for (const candidate of candidates) {
try {
const credentials = candidate.fallback ? candidate.credentials : credentialsFor(candidate.kind)
if (!credentials?.OPENAI_API_KEY) continue
return {
apiKey: credentials.OPENAI_API_KEY,
baseUrl: candidate.status.baseUrl,
model: candidate.status.model,
sourceKind: candidate.kind,
}
} catch { /* try the next configured secure profile */ }
}
throw new Error('MEDIA_GENERATION_PROVIDER_UNAVAILABLE')
}
module.exports = { mediaProviderScore, resolveMediaProvider }
+8 -15
View File
@@ -126,21 +126,6 @@ if (isLocalDocument) Object.assign(desktopApi, {
localOutcome: Object.freeze({
recordAdoption: input => ipcRenderer.invoke('local-outcome:record-adoption', input),
}),
localDelivery: Object.freeze({
save: input => ipcRenderer.invoke('local-delivery:save', input),
review: input => ipcRenderer.invoke('local-delivery:review', input),
revise: input => ipcRenderer.invoke('local-delivery:revise', input),
reuse: input => ipcRenderer.invoke('local-delivery:reuse', input),
export: input => ipcRenderer.invoke('local-delivery:export', input),
}),
workBuddy: Object.freeze({
settings: () => ipcRenderer.invoke('local-workbuddy:settings'),
setModel: modelId => ipcRenderer.invoke('local-workbuddy:set-model', modelId),
}),
workPreferences: Object.freeze({
get: () => ipcRenderer.invoke('local-work-preferences:get'),
save: input => ipcRenderer.invoke('local-work-preferences:save', input),
}),
agentInstaller: Object.freeze({
catalog: () => ipcRenderer.invoke('local-agent-installer:catalog'),
skills: kind => ipcRenderer.invoke('local-agent-installer:skills', kind),
@@ -189,8 +174,16 @@ if (isLocalDocument) Object.assign(desktopApi, {
),
preview: id => ipcRenderer.invoke('local-attachments:preview', id),
open: id => ipcRenderer.invoke('local-attachments:open', id),
save: id => ipcRenderer.invoke('local-attachments:save', 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,6 +7,7 @@ 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'],
])
+182
View File
@@ -0,0 +1,182 @@
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 {
env: {
...generic,
ANTHROPIC_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,
}
+378
View File
@@ -0,0 +1,378 @@
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 activePreset = 'official'
const profiles = {}
if (encryptionAvailable && credentialStored) {
try {
const agent = this.readDocument().agents[targetKind]
if (agent) {
activePreset = agent.activePreset
for (const [preset, entry] of Object.entries(agent.profiles)) {
try {
this.decryptEntry(entry)
profiles[preset] = {
provider: entry.provider,
baseUrl: entry.baseUrl,
model: entry.model,
configured: true,
}
} catch { /* keep an unreadable profile unavailable */ }
}
const activeEntry = profiles[activePreset]
if (activeEntry) {
metadata = activeEntry
configured = true
}
}
} catch { /* unreadable credentials stay unavailable */ }
}
return {
provider: metadata.provider,
baseUrl: metadata.baseUrl,
model: metadata.model,
activePreset,
profiles,
encryptionAvailable,
configured,
}
}
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 }
+7 -51
View File
@@ -1,11 +1,3 @@
const {
authFailureCertainty,
isNetworkFailure,
isProviderUnavailable,
isRateLimited,
retryAfterFromText,
} = require('./failure-signals.cjs')
const FAILURE_CATEGORIES = Object.freeze([
'authentication',
'compatibility',
@@ -129,47 +121,23 @@ function retryAfterMs(error, now) {
const parsed = parseRetryAfter(headerValue(headers, 'retry-after'), now)
if (parsed != null) return parsed
}
// 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 ''
return null
}
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'
// 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 === 401 || status === 403 || code === 'LOCAL_AGENT_AUTH_REQUIRED'
|| /unauthori[sz]ed|forbidden|invalid token/i.test(detail)) return 'authentication'
if (status === 429 || /RATE_LIMIT|TOO_MANY_REQUESTS/.test(code)
|| isRateLimited(detail)) return 'rate_limit'
|| /rate limit|too many requests/i.test(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'
@@ -276,30 +244,18 @@ function retryDecision(input, options = {}) {
if (safety?.sideEffectsPossible === true && safety.idempotencyMode !== 'durable') {
return Object.freeze({ ...base, action: 'human_gate', exhausted: false })
}
const configuredMaxDelayMs = Math.max(1, boundedInteger(
const maxDelayMs = 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, minimumDelay),
delayMs: Math.max(backoff, requestedDelay),
})
}
-129
View File
@@ -1,129 +0,0 @@
// 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,
}
-144
View File
@@ -1,144 +0,0 @@
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
const activeToolIds = new Set()
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(toolCallId = '') {
if (toolCallId) activeToolIds.add(String(toolCallId))
toolActive = true
progress()
},
toolFinished(toolCallId = '') {
if (toolCallId) activeToolIds.delete(String(toolCallId))
else activeToolIds.clear()
toolActive = activeToolIds.size > 0
progress()
},
// Waiting for a person (Human Gate) is not the Agent being quiet.
pause() {
paused = true
disarm()
},
resume() {
paused = false
lastProgressAt = clock.now()
lastSubstantiveAt = lastProgressAt
arm()
},
close() {
closed = true
disarm()
},
}
}
module.exports = { createInvocationWatchdog }
-4
View File
@@ -122,8 +122,6 @@ function normalizeSessionMeta(input) {
meta.inheritedTaskIds = inheritedTaskIds
meta.provenanceCompleteness = provenanceCompleteness
}
const lastDeliveredMessageId = cleanId(input.lastDeliveredMessageId)
if (lastDeliveredMessageId) meta.lastDeliveredMessageId = lastDeliveredMessageId
return meta
}
@@ -159,8 +157,6 @@ function nextSessionMeta(meta, usage = {}) {
if (Object.hasOwn(usage, field)) next[field] = usage[field]
else if (Object.hasOwn(previous, field)) next[field] = previous[field]
}
if (usage.lastDeliveredMessageId) next.lastDeliveredMessageId = usage.lastDeliveredMessageId
else if (previous.lastDeliveredMessageId) next.lastDeliveredMessageId = previous.lastDeliveredMessageId
return normalizeSessionMeta(next)
}
@@ -1,5 +1,4 @@
const MAX_CAPSULE_EVENTS = 12
const { parseTaskDecision } = require('../collaboration/task-decision.cjs')
const { redactSecrets } = require('../security/secret-redaction.cjs')
const { normalizeContentBlobRef } = require('../attachments/content-blob-store.cjs')
@@ -273,10 +272,6 @@ function normalizeContextStats(input) {
if (externalRunRef) context.externalRunRef = externalRunRef
const outcomeRefs = normalizeOutcomeRefs(input.outcomeRefs)
if (Object.keys(outcomeRefs).length) context.outcomeRefs = outcomeRefs
if (input.taskDecision != null) {
try { context.taskDecision = parseTaskDecision(input.taskDecision) }
catch { /* malformed task judgments cannot authorize completion */ }
}
const connectorContext = normalizeConnectorContext(input)
if (connectorContext) Object.assign(context, connectorContext)
return context
+1 -6
View File
@@ -149,7 +149,7 @@ class RunHarness {
return next
}
beginAgent(kind, round = 0, sourceMessageIds = [], options = {}) {
beginAgent(kind, round = 0, sourceMessageIds = []) {
const safeKind = cleanId(kind)
if (!safeKind) throw new Error('RUN_HARNESS_AGENT_REQUIRED')
if (!this.targetKinds.includes(safeKind)) {
@@ -184,10 +184,6 @@ 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) {
@@ -332,7 +328,6 @@ class RunHarness {
seenSeqs: [...run.seenSeqs],
eventCursor: run.eventCursor,
context: { ...run.context },
...(run.turnSeq ? { turnSeq: run.turnSeq } : {}),
}))
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ const CONTEXT_FIELDS = new Set([
'contextMode', 'promptChars', 'contextPackId', 'contextPackState', 'deliveryRecordIds',
'sessionProvenance', 'outcomeRefs', 'sourceCount', 'sourceHash', 'promptBytes',
'promptHash', 'wirePayloadBytes', 'wirePayloadHash', 'operationId', 'snapshotHash',
'connector', 'connectorEventState', 'taskDecision',
'connector', 'connectorEventState',
])
function clone(value) {
+8 -18
View File
@@ -1,5 +1,4 @@
const { redactSecrets } = require('../security/secret-redaction.cjs')
const { parseTaskDecision } = require('../collaboration/task-decision.cjs')
const { normalizeExternalRunRef } = require('../agents/agent-runtime-contract.cjs')
const { parseConnectorRunSnapshot } = require('../agents/connectors/agent-connector-registry.cjs')
const { parseRunEventState } = require('./run-event-protocol.cjs')
@@ -44,7 +43,7 @@ const CONTEXT_FIELDS = new Set([
'contextMode', 'promptChars', 'contextPackId', 'contextPackState', 'deliveryRecordIds',
'sessionProvenance', 'outcomeRefs', 'sourceCount', 'sourceHash', 'promptBytes',
'promptHash', 'wirePayloadBytes', 'wirePayloadHash', 'operationId', 'snapshotHash',
'connector', 'connectorEventState', 'taskDecision',
'connector', 'connectorEventState',
])
const CONTEXT_PACK_STATES = new Set(['captured', 'legacy-unavailable'])
const REQUIRED_BUDGET_FIELDS = new Set(['limits', 'used', 'source', 'enforcement', 'startedAt'])
@@ -67,7 +66,7 @@ const CONTINUATION_V4_PHASES = new Set([
])
const CONTINUATION_GATE_TYPES = new Set(['permission', 'budget', 'decision', 'retry', 'input'])
const CONTINUATION_RESUME_KINDS = new Set([
'agent_slot', 'role_review_decision', 'v4_human_gate', 'v4_synthesis_recovery', 'v4_task_decision',
'agent_slot', 'role_review_decision', 'v4_human_gate', 'v4_synthesis_recovery',
])
const CONTINUATION_STATES = new Set([
'pending', 'ready', 'resuming', 'completed', 'failed', 'cancelled',
@@ -78,7 +77,7 @@ const ORCHESTRATION_FIELDS = new Set([
'successfulKinds', 'agreementKinds', 'attachmentRecipients',
'totalSuccesses', 'terminalFailureOccurred', 'executionSequence',
'collaboration', 'template', 'taskGraph',
'phase', 'discussionStyle', 'discussionWriterKind', 'batchId', 'round', 'currentKinds', 'snapshotHash', 'snapshot', 'plan',
'phase', 'discussionStyle', 'batchId', 'round', 'currentKinds', 'snapshotHash', 'snapshot', 'plan',
'slots', 'deliveryWatermarks', 'deliveryState', 'commitState', 'challengeBindings', 'synthesisBinding',
'synthesisRecovery', 'convergence', 'coordinationPlan', 'workReceipts', 'candidateCommit',
])
@@ -241,7 +240,7 @@ function normalizeAgentRun(input, parent, fallbackTimestamp) {
'includedCount', 'omittedCount', 'charCount', 'sessionRotated', 'externalRunRef',
'contextMode', 'promptChars', 'contextPackId', 'deliveryRecordIds', 'sessionProvenance', 'outcomeRefs',
'sourceCount', 'sourceHash', 'promptBytes', 'promptHash', 'wirePayloadBytes', 'wirePayloadHash',
'operationId', 'snapshotHash', 'connector', 'connectorEventState', 'taskDecision',
'operationId', 'snapshotHash', 'connector', 'connectorEventState',
].some(key => hasOwn(input.context, key))
const rawEvents = Array.isArray(input.events) ? input.events : []
const rawSourceIds = Array.isArray(input.sourceMessageIds) ? input.sourceMessageIds : []
@@ -405,18 +404,16 @@ function normalizeContinuation(input) {
|| !CONTINUATION_STATES.has(state) || !agentRunId || !agentKind
|| (hasPublicAgentRunId && !publicAgentRunId)
|| (resumeKind === 'role_review_decision' && gateType !== 'decision')
|| (resumeKind === 'v4_task_decision'
&& (gateType !== 'input' || !hasInvocationBinding || phase !== 'discussion'))
|| (resumeKind === 'v4_human_gate'
&& (gateType !== 'decision' || !hasStateEpoch || stateEpoch < 1))
|| (resumeKind === 'v4_synthesis_recovery'
&& (gateType !== 'decision' || !hasStateEpoch))
|| (!['v4_human_gate', 'v4_synthesis_recovery'].includes(resumeKind) && hasStateEpoch)
|| (invocationFieldCount > 0 && !hasInvocationBinding)
|| (hasInvocationBinding && (!['agent_slot', 'v4_task_decision'].includes(resumeKind)
|| (hasInvocationBinding && (resumeKind !== 'agent_slot'
|| !CONTINUATION_V4_PHASES.has(phase) || !slotId || !operationId
|| !SHA256.test(snapshotHash)))
|| (gateType === 'input' && resumeKind !== 'v4_task_decision' && !hasRequestBinding)
|| (gateType === 'input' && !hasRequestBinding)
|| (!hasRequestBinding && [requestId, requestHash, sessionRefHash, sessionProvenanceHash]
.some(Boolean))) return undefined
return {
@@ -445,8 +442,7 @@ function continuationBindingMatchesOrchestration(continuation, orchestration) {
}
const hasBinding = CONTINUATION_INVOCATION_FIELDS.every(field => hasOwn(continuation || {}, field))
if (!hasBinding) return true
if (!['agent_slot', 'v4_task_decision'].includes(continuation.resumeKind)
|| orchestration?.version !== 4) return false
if (continuation.resumeKind !== 'agent_slot' || orchestration?.version !== 4) return false
if (!['pending', 'ready', 'resuming'].includes(continuation.state)) return true
const slots = orchestration.slots.filter(slot => slot.slotId === continuation.slotId)
const orchestrationRound = orchestration.workflow === 'manual'
@@ -496,7 +492,7 @@ function normalizeOrchestration(input) {
const totalSuccesses = boundedNumber(input.totalSuccesses, 0, 1000000)
const executionSequence = boundedNumber(input.executionSequence, 0, 1000000)
const v4OnlyFields = [
'phase', 'discussionStyle', 'discussionWriterKind', 'batchId', 'round', 'currentKinds', 'snapshotHash', 'snapshot', 'plan',
'phase', 'discussionStyle', 'batchId', 'round', 'currentKinds', 'snapshotHash', 'snapshot', 'plan',
'slots', 'deliveryWatermarks', 'deliveryState', 'commitState', 'challengeBindings',
'synthesisBinding', 'synthesisRecovery', 'convergence', 'candidateCommit',
]
@@ -737,12 +733,6 @@ function hasValidStoredRecordShape(input) {
|| canonicalJson(normalizedProvenance)
!== canonicalJson(agentRun.context.sessionProvenance)) return false
}
if (hasOwn(agentRun.context, 'taskDecision')) {
try {
if (canonicalJson(parseTaskDecision(agentRun.context.taskDecision))
!== canonicalJson(agentRun.context.taskDecision)) return false
} catch { return false }
}
if (hasOwn(agentRun.context, 'outcomeRefs')) {
if (!isRecord(agentRun.context.outcomeRefs)) return false
const normalizedOutcomeRefs = normalizeOutcomeRefs(agentRun.context.outcomeRefs)
+1 -1
View File
@@ -311,7 +311,7 @@ class RunLedger {
persist(runs = this.runs) {
atomicWritePrivateFile(
this.storagePath,
`${JSON.stringify({ version: STORE_VERSION, runs })}\n`,
`${JSON.stringify({ version: STORE_VERSION, runs }, null, 2)}\n`,
)
}
-13
View File
@@ -58,7 +58,6 @@ class RunScheduler {
this.activeGlobal = 0
this.activeTasks = new Map()
this.activeWorkspaces = new Map()
this.activeWorkspaceWriters = new Set()
this.queue = []
this.sequence = 0
schedulerPauseTokens.set(this, new Map())
@@ -70,8 +69,6 @@ class RunScheduler {
canGrant(entry) {
return taskPauseAllows(this, entry)
&& (entry.permissionMode !== 'workspace-write'
|| !this.activeWorkspaceWriters.has(entry.workspaceKey))
&& belowLimit(this.activeGlobal, this.limits.global)
&& belowLimit(this.resourceCount(this.activeTasks, entry.taskId), this.limits.task)
&& belowLimit(this.resourceCount(this.activeWorkspaces, entry.workspaceKey), this.limits.workspace)
@@ -87,14 +84,12 @@ class RunScheduler {
this.activeGlobal += 1
this.changeCount(this.activeTasks, entry.taskId, 1)
this.changeCount(this.activeWorkspaces, entry.workspaceKey, 1)
if (entry.permissionMode === 'workspace-write') this.activeWorkspaceWriters.add(entry.workspaceKey)
entry.signal?.removeEventListener('abort', entry.abortHandler)
let released = false
const lease = Object.freeze({
leaseId: entry.requestId,
taskId: entry.taskId,
workspaceKey: entry.workspaceKey,
permissionMode: entry.permissionMode,
queuedAt: entry.queuedAt,
acquiredAt: this.now(),
release: () => {
@@ -103,7 +98,6 @@ class RunScheduler {
this.activeGlobal -= 1
this.changeCount(this.activeTasks, entry.taskId, -1)
this.changeCount(this.activeWorkspaces, entry.workspaceKey, -1)
if (entry.permissionMode === 'workspace-write') this.activeWorkspaceWriters.delete(entry.workspaceKey)
this.drain()
return true
},
@@ -111,7 +105,6 @@ class RunScheduler {
schedulerLeaseBindings.set(lease, Object.freeze({
taskId: entry.taskId,
workspaceKey: entry.workspaceKey,
permissionMode: entry.permissionMode,
signal: entry.signal,
}))
entry.resolve(lease)
@@ -136,13 +129,9 @@ class RunScheduler {
#enqueueAcquire(input = {}, pauseToken = null) {
let taskId
let workspaceKey
const permissionMode = input.permissionMode == null ? 'read-only' : input.permissionMode
try {
taskId = resourceKey(input.taskId, 'RUN_SCHEDULER_TASK_REQUIRED')
workspaceKey = resourceKey(input.workspaceKey, 'RUN_SCHEDULER_WORKSPACE_REQUIRED')
if (!['read-only', 'workspace-write'].includes(permissionMode)) {
throw schedulerError('RUN_SCHEDULER_PERMISSION_INVALID')
}
} catch (error) {
return Promise.reject(error)
}
@@ -153,7 +142,6 @@ class RunScheduler {
requestId: `lease-${++this.sequence}`,
taskId,
workspaceKey,
permissionMode,
queuedAt: this.now(),
signal,
resolve,
@@ -188,7 +176,6 @@ class RunScheduler {
get leaseId() { return activeLease?.leaseId || '' },
get taskId() { return activeLease?.taskId || '' },
get workspaceKey() { return activeLease?.workspaceKey || '' },
get permissionMode() { return activeLease?.permissionMode || '' },
get queuedAt() { return activeLease?.queuedAt || 0 },
get acquiredAt() { return activeLease?.acquiredAt || 0 },
release: () => {
-86
View File
@@ -1,86 +0,0 @@
const fs = require('node:fs')
const path = require('node:path')
const { z } = require('zod')
const id = z.string().min(1).max(120).regex(/^[A-Za-z0-9._:-]+$/)
function request(schema, input) {
const parsed = schema.safeParse(input)
if (!parsed.success) throw new Error('LOCAL_DELIVERY_INVALID')
return parsed.data
}
function registerDeliveryIpc({ registerTrustedHandle, getWorkspace, getAttachmentStore, dialog, getMainWindow }) {
const workspace = () => {
const value = getWorkspace()
if (!value) throw new Error('LOCAL_WORKSPACE_UNAVAILABLE')
return value
}
for (const method of ['save', 'review', 'revise']) {
registerTrustedHandle(`local-delivery:${method}`, input => {
const current = workspace()
if (method === 'review' && input?.accept === true) {
const task = current.delivery.task(input.taskId)
const version = current.delivery.version(task, input.versionId)
for (const attachment of version.attachments) {
const store = getAttachmentStore()
if (!store) throw new Error('LOCAL_ATTACHMENT_STORAGE_UNAVAILABLE')
store.read(attachment.id)
}
}
const result = current.delivery[method](input)
current.emitChanged()
return { taskId: method === 'save' ? result : input.taskId }
})
}
registerTrustedHandle('local-delivery:reuse', input => {
const { taskId } = request(z.strictObject({ taskId: id }), input)
const current = workspace()
const task = current.delivery.task(taskId)
const original = current.getGroup(task.groupId)
const root = path.join(path.dirname(current.storagePath), 'delivery-workspaces')
fs.mkdirSync(root, { recursive: true, mode: 0o700 })
const workdir = fs.mkdtempSync(path.join(root, 'task-'))
let group
try {
group = current.createGroup({
name: task.brief.title, agentKinds: original.agentKinds,
conversationType: original.conversationType,
directAgentKind: original.directAgentKind,
workdir, allowWrite: false,
})
const newTaskId = current.delivery.save({ groupId: group.id, brief: task.brief })
current.emitChanged()
return { taskId: newTaskId, groupId: group.id }
} catch (error) {
if (group) current.deleteGroup(group.id)
fs.rmdirSync(workdir)
throw error
}
})
registerTrustedHandle('local-delivery:export', async input => {
const { taskId, versionId, attachmentId } = request(z.strictObject({
taskId: id, versionId: id, attachmentId: id.optional(),
}), input)
const current = workspace()
const task = current.delivery.task(taskId)
const version = current.delivery.version(task, versionId)
const attachment = attachmentId ? version.attachments.find(item => item.id === attachmentId) : null
if (attachmentId && !attachment) throw new Error('LOCAL_DELIVERY_NOT_FOUND')
let bytes
if (attachment) {
const store = getAttachmentStore()
if (!store) throw new Error('LOCAL_ATTACHMENT_STORAGE_UNAVAILABLE')
bytes = store.read(attachment.id)
} else bytes = Buffer.from(current.delivery.markdown(taskId, versionId), 'utf8')
const filename = (attachment?.name || `${task.brief.title}-V${task.versions.indexOf(version) + 1}.md`)
.replace(/[\\/\x00-\x1f:*?"<>|]/g, '_')
const result = await dialog.showSaveDialog(getMainWindow(), { defaultPath: filename })
if (result.canceled || !result.filePath) return { saved: false }
try {
fs.writeFileSync(result.filePath, bytes, { mode: 0o600, flag: fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW })
} catch { throw new Error('LOCAL_DELIVERY_EXPORT_FAILED') }
return { saved: true }
})
}
module.exports = { registerDeliveryIpc }
+36 -49
View File
@@ -1,8 +1,6 @@
const { ADOPTION_ACTION_STATUSES } = require('../collaboration/outcome-records.cjs')
const { statusForHumanGateOption } = require('../gates/human-gate-coordinator.cjs')
const { ATTACHMENT_FILE_EXTENSIONS } = require('../attachments/attachment-records.cjs')
const { registerDeliveryIpc } = require('./delivery-ipc.cjs')
const { loadedWorkPreferences, parseWorkPreferences } = require('../workspace/work-preferences.cjs')
const LOCAL_GROUP_IDENTIFIER = /^[^\u0000-\u001f\u007f]{1,100}$/u
const LOCAL_RUN_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/
@@ -165,7 +163,6 @@ function sanitizeCloudAgentSnapshot(snapshot) {
}
function registerDesktopIpc(options) {
registerDeliveryIpc(options)
const {
app,
attachmentIdsFromSnapshot,
@@ -181,7 +178,6 @@ function registerDesktopIpc(options) {
getOutcomeStore,
getSkillTrustStore,
getWorkspace,
getWorkBuddySettings,
installer,
isShutdownStarted,
knowledgeBaseStore,
@@ -189,6 +185,8 @@ function registerDesktopIpc(options) {
loadKnowledgeBaseSources,
localAgentCatalog,
openExternalUrl,
providerStore,
providerAgentKind,
refreshLocalAgentState,
registerTrustedHandle,
skillCatalog,
@@ -421,51 +419,6 @@ function registerDesktopIpc(options) {
})
registerTrustedHandle('local-workspace:default-directory', () => app.getPath('documents'))
registerTrustedHandle('local-agent-installer:catalog', async () => localAgentCatalog())
registerTrustedHandle('local-workbuddy:settings', () => getWorkBuddySettings())
registerTrustedHandle('local-work-preferences:get', () => loadedWorkPreferences(getWorkspace()?.state.workPreferences))
registerTrustedHandle('local-work-preferences:save', (...args) => {
if (args.length !== 1) throw new Error('LOCAL_WORK_PREFERENCES_INVALID')
const value = parseWorkPreferences(args[0])
const workspace = getWorkspace()
if (!workspace) throw new Error('LOCAL_WORKSPACE_UNAVAILABLE')
if (workspace.activeRuns.size || workspace.preparingRuns?.size) throw new Error('LOCAL_WORK_PREFERENCES_BUSY')
const previous = workspace.state.workPreferences
workspace.state.workPreferences = value
try { workspace.save() } catch (error) {
if (previous) workspace.state.workPreferences = previous
else delete workspace.state.workPreferences
throw error
}
workspace.emitChanged()
return value
})
registerTrustedHandle('local-workbuddy:set-model', async (...args) => {
if (args.length !== 1 || typeof args[0] !== 'string' || args[0].length > 120) {
throw new Error('LOCAL_WORKBUDDY_MODEL_UNAVAILABLE')
}
const workspace = getWorkspace()
if (!workspace) throw new Error('LOCAL_WORKSPACE_UNAVAILABLE')
const modelId = args[0]
const settings = await getWorkBuddySettings()
if (modelId && !settings.models.some(model => model.id === modelId)) {
throw new Error('LOCAL_WORKBUDDY_MODEL_UNAVAILABLE')
}
if (workspace.activeRuns.size || workspace.preparingRuns?.size) {
throw new Error('LOCAL_WORKBUDDY_BUSY')
}
const before = workspace.state.agentPreferences.workbuddy
const runtime = workspace.state.agentRuntime.workbuddy
workspace.state.agentPreferences.workbuddy = { ...before, modelId }
delete workspace.state.agentRuntime.workbuddy
try { workspace.save() } catch (error) {
if (before) workspace.state.agentPreferences.workbuddy = before
else delete workspace.state.agentPreferences.workbuddy
if (runtime) workspace.state.agentRuntime.workbuddy = runtime
throw error
}
await refreshLocalAgentState()
return { ...settings, modelId }
})
registerTrustedHandle('local-agent-installer:skills', async (kind) => {
const selectedKind = String(kind || '')
if (customAgentStore.has(selectedKind)) {
@@ -706,7 +659,41 @@ function registerDesktopIpc(options) {
registerTrustedHandle('local-attachments:import', input => attachments.importBuffer(input))
registerTrustedHandle('local-attachments:preview', id => attachments.preview(String(id || '')))
registerTrustedHandle('local-attachments:open', id => attachments.open(String(id || '')))
registerTrustedHandle('local-attachments:save', id => attachments.save(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?.()
-257
View File
@@ -1,257 +0,0 @@
const { randomUUID } = require('node:crypto')
const { z } = require('zod')
const { normalizeAttachmentMetadata } = require('./local-workspace-message-records.cjs')
const id = z.string().min(1).max(120).regex(/^[A-Za-z0-9._:-]+$/)
const text = (max) => z.string().trim().min(1).max(max)
const briefSchema = z.strictObject({
title: text(120), goal: text(4000), output: text(2000),
outputKind: z.enum(['text', 'files']),
criteria: z.array(text(300)).min(1).max(20),
language: z.enum(['zh', 'en']),
})
const attachmentSchema = z.strictObject({
id, name: text(160), mimeType: text(160), size: z.number().int().positive(),
})
const sourceSchema = z.strictObject({ name: text(200), kind: text(40), attachmentId: id.optional() })
const revisionSchema = z.strictObject({ baseVersionId: id, note: text(4000) })
const attemptSchema = z.strictObject({
runId: id, messageId: id, status: text(40), createdAt: text(80),
input: z.string().max(200000), sources: z.array(sourceSchema).max(41),
revision: revisionSchema.nullable(),
})
const versionSchema = z.strictObject({
id, runId: id, createdAt: text(80),
responses: z.array(z.strictObject({ messageId: id, agentKind: text(40), content: z.string().max(1000000) })).max(32),
attachments: z.array(attachmentSchema).max(128),
checks: z.array(z.enum(['pending', 'met', 'unmet', 'unknown'])).max(20),
notes: z.string().max(4000), acceptedAt: z.string().max(80),
})
const taskSchema = z.strictObject({
id, groupId: text(100), brief: briefSchema, createdAt: text(80),
attempts: z.array(attemptSchema).max(200), versions: z.array(versionSchema).max(200),
revision: revisionSchema.nullable(),
})
function parse(schema, input) {
const result = schema.safeParse(input)
if (!result.success) throw new Error('LOCAL_DELIVERY_INVALID')
return result.data
}
function parseDeliveryTasks(value = []) {
return parse(z.array(taskSchema).max(1000), value)
}
class DeliveryTasks {
constructor(workspace) {
this.workspace = workspace
this.pending = new Map()
}
get tasks() { return this.workspace.state.deliveryTasks || [] }
task(taskId) {
parse(id, taskId)
const task = this.tasks.find(item => item.id === taskId)
if (!task || !this.workspace.state.groups.some(group => group.id === task.groupId)) {
throw new Error('LOCAL_DELIVERY_NOT_FOUND')
}
return task
}
version(task, versionId) {
parse(id, versionId)
const version = task.versions.find(item => item.id === versionId)
if (!version) throw new Error('LOCAL_DELIVERY_NOT_FOUND')
return version
}
commit(change) {
const previous = structuredClone(this.tasks)
try {
this.workspace.state.deliveryTasks ||= []
const result = change()
parseDeliveryTasks(this.tasks)
this.workspace.save()
return result
} catch (error) {
this.workspace.state.deliveryTasks = previous
throw error
}
}
idle(task) {
if (this.workspace.isGroupBusy(task.groupId) || this.pending.has(task.groupId)) {
throw new Error('LOCAL_GROUP_RUNNING')
}
}
save(input) {
const request = parse(z.strictObject({ groupId: text(100), taskId: id.optional(), brief: briefSchema }), input)
this.workspace.getGroup(request.groupId)
if (this.workspace.isGroupBusy(request.groupId)) throw new Error('LOCAL_GROUP_RUNNING')
const existing = request.taskId ? this.task(request.taskId) : null
if (existing && (existing.groupId !== request.groupId || existing.attempts.length)) {
throw new Error('LOCAL_DELIVERY_LOCKED')
}
return this.commit(() => {
if (existing) { existing.brief = request.brief; return existing.id }
const task = {
id: `delivery-${randomUUID()}`, groupId: request.groupId, brief: request.brief,
createdAt: this.workspace.now(), attempts: [], versions: [], revision: null,
}
this.tasks.push(task)
return task.id
})
}
prepare(input) {
const task = this.task(input.deliveryTaskId)
if (task.groupId !== input.groupId || input.regenerateMessageId || input.threadRootId) {
throw new Error('LOCAL_DELIVERY_INVALID')
}
this.idle(task)
if (task.versions.length && !task.revision) throw new Error('LOCAL_DELIVERY_REVISION_REQUIRED')
const brief = task.brief
const base = task.revision ? this.version(task, task.revision.baseVersionId) : null
if (task.attempts.length >= 200) throw new Error('LOCAL_DELIVERY_INPUT_LIMIT')
if (input.attachments != null && !Array.isArray(input.attachments)) throw new Error('LOCAL_DELIVERY_INVALID')
const attachments = [...new Map([...(base?.attachments || []), ...(input.attachments || [])]
.map(item => [item.id, item])).values()]
if (attachments.length > 32) throw new Error('LOCAL_ATTACHMENT_COUNT_LIMIT')
const prompt = [
brief.language === 'zh' ? '工作交付要求(请用中文回答)' : 'Work delivery requirements (respond in English)',
JSON.stringify(brief),
'Produce the requested deliverable. Identify source materials for key claims, conflicting or missing information, and unresolved issues. Never invent missing data or claim that a file exists without creating it. Human acceptance is separate from your self-review.',
...(base ? ['Previous version:', ...base.responses.map(item => item.content), `Revision request: ${task.revision.note}`] : []),
`Additional instructions: ${String(input.text || '')}`,
].join('\n\n')
if (prompt.length > 200000) throw new Error('LOCAL_DELIVERY_INPUT_LIMIT')
this.pending.set(task.groupId, { taskId: task.id, input: String(input.text || '') })
const { deliveryTaskId: _id, ...rest } = input
return { ...rest, text: prompt, attachments }
}
bind(groupId, controller, messageId) {
const pending = this.pending.get(groupId)
if (!pending) return
const task = this.task(pending.taskId)
const message = this.workspace.state.messages.find(item => item.id === messageId)
if (!message || message.groupId !== groupId) throw new Error('LOCAL_DELIVERY_INVALID')
this.commit(() => task.attempts.push({
runId: controller.runId, messageId, status: 'running', createdAt: this.workspace.now(),
input: pending.input, revision: task.revision ? { ...task.revision } : null,
sources: [
{ name: this.workspace.getGroup(groupId).name || 'Conversation', kind: 'conversation' },
...(message.attachments || []).map(item => ({ name: item.name, kind: 'attachment', attachmentId: item.id })),
...(message.knowledgeBaseHints || []).map(item => ({ name: item.name, kind: item.kind })),
...(message.skillHints || []).map(item => ({ name: item.name, kind: 'skill' })),
],
}))
}
complete(runId, status, committedMessageIds = []) {
const task = this.tasks.find(item => item.attempts.some(attempt => attempt.runId === runId))
if (!task) return
const attempt = task.attempts.find(item => item.runId === runId)
if (!['running', 'waiting', 'preparing'].includes(attempt.status)) return
const messages = this.workspace.state.messages
const rootIndex = messages.findIndex(item => item.id === attempt.messageId)
const nextUserIndex = messages.findIndex((item, index) => index > rootIndex && item.groupId === task.groupId && item.role === 'user')
const related = messages.filter((message, index) => {
if (message.role !== 'agent' || message.groupId !== task.groupId) return false
if (committedMessageIds.includes(message.id)) return true
if (message.trace?.runId && message.trace.runId !== runId) return false
return message.trace?.runId === runId
|| message.threadRootId === attempt.messageId
|| (!message.threadRootId && rootIndex >= 0 && index > rootIndex && (nextUserIndex < 0 || index < nextUserIndex))
})
const outputs = related.filter(message => {
const phase = message.trace?.phase || message.trace?.context?.phase
return committedMessageIds.includes(message.id)
|| !['proposal', 'discussion', 'challenge', 'coordination', 'work', 'verification'].includes(phase)
}).filter(message => message.content?.trim())
const attachments = [...new Map(related.flatMap(item => item.attachments || [])
.map(normalizeAttachmentMetadata).filter(Boolean).map(item => [item.id, item])).values()]
this.commit(() => {
attempt.status = status
if (!outputs.length && !attachments.length) return
task.versions.push({
id: `version-${randomUUID()}`, runId, createdAt: this.workspace.now(),
responses: outputs.map(item => ({ messageId: item.id, agentKind: item.agentKind, content: item.content || '' })),
attachments, checks: task.brief.criteria.map(() => 'pending'), notes: '', acceptedAt: '',
})
task.revision = null
})
}
recover() {
for (const task of this.tasks) {
for (const attempt of task.attempts) {
if (!['running', 'waiting', 'preparing'].includes(attempt.status)) continue
const record = this.workspace.runLedger?.list?.().find(item => item.runId === attempt.runId)
if (this.workspace.activeRuns.has(task.groupId)
|| (record?.status === 'waiting' && this.workspace.canResumeHumanGateRecord(record))) continue
const status = record && !['running', 'waiting', 'preparing', 'queued'].includes(record.status)
? record.status : 'interrupted'
this.complete(attempt.runId, status, [
...(record?.orchestration?.commitState?.messageIds || []),
record?.orchestration?.candidateCommit?.messageId,
].filter(Boolean))
}
}
}
review(input) {
const request = parse(z.strictObject({
taskId: id, versionId: id, checks: z.array(z.enum(['pending', 'met', 'unmet', 'unknown'])).max(20),
notes: z.string().max(4000), accept: z.boolean(),
}), input)
const task = this.task(request.taskId)
this.idle(task)
const version = this.version(task, request.versionId)
if (version.acceptedAt || request.checks.length !== task.brief.criteria.length) throw new Error('LOCAL_DELIVERY_LOCKED')
if (request.accept && (request.checks.some(check => check !== 'met')
|| (task.brief.outputKind === 'files' && !version.attachments.length))) {
throw new Error('LOCAL_DELIVERY_NOT_READY')
}
this.commit(() => {
version.checks = request.checks
version.notes = request.notes.trim()
if (request.accept) version.acceptedAt = this.workspace.now()
})
}
revise(input) {
const request = parse(z.strictObject({ taskId: id, baseVersionId: id, note: text(4000) }), input)
const task = this.task(request.taskId)
this.idle(task)
this.version(task, request.baseVersionId)
this.commit(() => { task.revision = { baseVersionId: request.baseVersionId, note: request.note } })
}
markdown(taskId, versionId) {
const task = this.task(taskId)
const version = this.version(task, versionId)
const attempt = task.attempts.find(item => item.runId === version.runId)
const zh = task.brief.language === 'zh'
const checks = zh
? { pending: '待检查', met: '符合', unmet: '不符合', unknown: '无法判断' }
: { pending: 'Not checked', met: 'Met', unmet: 'Not met', unknown: 'Unknown' }
return [
`# ${task.brief.title}`, `V${task.versions.indexOf(version) + 1} · ${version.createdAt}`,
`${zh ? '采用时间' : 'Accepted at'}: ${version.acceptedAt || (zh ? '未采用' : 'Not accepted')}`,
...version.responses.map(item => `## ${item.agentKind}\n\n${item.content}`),
`## ${zh ? '输入材料(不代表逐条引用已核实)' : 'Input materials (not verified claim-level citations)'}`,
...attempt.sources.map(item => `- ${item.name} (${item.kind})`),
`## ${zh ? '人工验收' : 'Human review'}`,
...task.brief.criteria.map((criterion, index) => `- [${checks[version.checks[index]]}] ${criterion}`),
`## ${zh ? '备注与未决事项' : 'Notes and unresolved issues'}`, version.notes || '—',
`## ${zh ? '附件(需单独导出)' : 'Attachments (export separately)'}`,
...version.attachments.map(item => `- ${item.name}`),
].join('\n\n') + '\n'
}
}
module.exports = { DeliveryTasks, parseDeliveryTasks }
@@ -1,37 +0,0 @@
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 }
@@ -1,72 +0,0 @@
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 }
@@ -1,113 +0,0 @@
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 }
@@ -1,63 +0,0 @@
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 }
@@ -1,156 +0,0 @@
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,
}
@@ -3,21 +3,6 @@ const {
} = require('../agents/agent-runtime-contract.cjs')
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
const checkedAt = Date.parse(String(runtime.checkedAt || ''))
const current = Date.parse(String(now || ''))
return !Number.isFinite(checkedAt) || (Number.isFinite(current)
&& (current < checkedAt || current - checkedAt >= AUTH_FAILURE_RETRY_MS))
}
function agentVersionIdentified(agent) {
if (agent?.custom === true) return true
@@ -49,6 +34,7 @@ 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
@@ -56,7 +42,6 @@ class LocalWorkspaceAgentCatalog {
this.now = options.now
this.refreshTask = null
this.refreshAgain = false
this.indeterminateSince = new Map()
}
async refresh() {
@@ -81,31 +66,27 @@ 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) => {
try {
return await this.credentialState(agent.kind, agent)
} catch {
return { state: 'unknown', source: 'native-probe-failed' }
const nativeStates = await Promise.all(detected.map((agent) => {
const runtime = runtimeAtRefreshStart.get(agent.kind)
const sharedProviderReady = Boolean(this.sharedProviderReady(agent.kind))
if (sharedProviderReady && runtime?.credentialState === 'missing') {
return { state: 'missing', source: 'runtime-auth-failure' }
}
if (sharedProviderReady) {
return { state: 'ready', source: 'shared-provider' }
}
return this.credentialState(agent.kind, agent)
}))
const state = this.state()
let recoveredRuntimeCredential = false
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 workBuddyUnavailable = ['workbuddy-desktop-locked', 'workbuddy-model-unavailable'].includes(native?.source)
const authoritativeNativeState = native?.source === 'native-auth-status'
// Give corrected native credentials a retry after the cooldown, never erase a newer failure.
if (expiredAuthFailure(runtime, this.now())
&& !(authoritativeNativeState && nativeState === 'ready')
&& runtime === runtimeAtRefreshStart.get(agent.kind)) {
delete state.agentRuntime[agent.kind]
runtime = undefined
recoveredRuntimeCredential = true
}
const sharedProviderRequired = native?.source === 'shared-provider-required'
// A probe that started before a newer runtime failure cannot clear that failure.
if (authoritativeNativeState && nativeState === 'ready'
&& ['missing', 'unknown'].includes(runtime?.credentialState)
@@ -125,39 +106,32 @@ 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 || nativeState === 'missing') {
if (runtimeMissing || sharedProviderRequired || nativeState === 'missing') {
credentialState = 'missing'
}
if (!runtimeMissing && nativeState === 'ready') credentialState = 'ready'
if (!runtimeMissing && sharedProviderReady) credentialState = 'ready'
else if (!runtimeMissing && nativeState === 'ready') credentialState = 'ready'
else if (!runtimeMissing && verifiedReady && nativeState !== 'missing') credentialState = 'ready'
if (retained && retained.credentialState === 'ready') credentialState = 'ready'
const installed = true
const versionIdentified = agentVersionIdentified(agent)
const compatible = agentCompatible(agent)
const nativeConfigurationUnknown = !runtimeMissing
&& !workBuddyUnavailable
&& nativeState === 'unknown'
&& native?.source !== 'native-runtime-unavailable'
&& native?.source !== 'native-probe-failed'
const configured = retained !== null
const configured = sharedProviderReady
|| nativeState === 'ready'
|| native?.source === 'native-auth-status'
|| native?.source === 'native-runtime-unavailable'
|| verifiedReady
|| runtimeMissing
|| nativeConfigurationUnknown
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 authenticated = !runtimeMissing && nativeState !== 'missing' && (
sharedProviderReady || nativeState === 'ready' || verifiedReady || nativeConfigurationUnknown
)
const runtimePrerequisitesReady = native?.source !== 'native-runtime-unavailable'
const invocable = installed && versionIdentified && compatible && configured
&& authenticated && runtimePrerequisitesReady && !workBuddyUnavailable
&& authenticated && runtimePrerequisitesReady
const verifiedRecently = (authoritativeNativeState && nativeState === 'ready')
|| recentlyVerified(runtime, this.now())
const available = invocable
@@ -169,14 +143,11 @@ class LocalWorkspaceAgentCatalog {
let availabilitySource = 'unverified'
if (!compatible) availabilitySource = 'incompatible'
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 (native?.source === 'native-runtime-unavailable') availabilitySource = 'native-runtime-unavailable'
else if (nativeReadySource) availabilitySource = nativeReadySource
else if (verifiedReady) availabilitySource = 'verified-run'
else if (nativeConfigurationUnknown) availabilitySource = 'local-cli'
if (retained) availabilitySource = retained.availabilitySource
if (workBuddyUnavailable) availabilitySource = native.source
return {
...agent,
installed,
@@ -192,7 +163,8 @@ class LocalWorkspaceAgentCatalog {
task: capabilities.task,
resumable: capabilities.resumable,
capabilities,
showInSidebar: typeof preferred === 'boolean' ? preferred : true,
showInSidebar: available
&& (typeof preferred === 'boolean' ? preferred : true),
}
})
this.setDetectedAgents(agents)
@@ -201,33 +173,13 @@ 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')
if (visible && !agent.available) throw new Error('LOCAL_AGENT_UNAVAILABLE')
const state = this.state()
state.agentPreferences[kind] = { ...state.agentPreferences[kind], showInSidebar: Boolean(visible) }
agent.showInSidebar = Boolean(visible)
state.agentPreferences[kind] = { showInSidebar: Boolean(visible) }
agent.showInSidebar = agent.available && Boolean(visible)
this.save()
this.emitChanged()
return this.snapshot()
@@ -239,9 +191,8 @@ class LocalWorkspaceAgentCatalog {
credentialState,
checkedAt: this.now(),
}
if (credentialState === 'ready') this.indeterminateSince.delete(kind)
const agent = this.detectedAgents().find(item => item.kind === kind)
if (agent && !(credentialState === 'unknown' && agent.authenticated === true)) {
if (agent) {
agent.credentialState = credentialState
agent.versionIdentified = agentVersionIdentified(agent)
agent.compatible = agentCompatible(agent)
@@ -259,7 +210,8 @@ class LocalWorkspaceAgentCatalog {
? 'runtime-auth-failure'
: 'unverified'
const preferred = state.agentPreferences[kind]?.showInSidebar
agent.showInSidebar = typeof preferred === 'boolean' ? preferred : true
agent.showInSidebar = agent.available
&& (typeof preferred === 'boolean' ? preferred : true)
}
this.save()
this.emitChanged()
File diff suppressed because it is too large. Load diff
@@ -1,261 +0,0 @@
// 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,135 +0,0 @@
const { hashValue } = require('../collaboration/orchestration-v4-records.cjs')
const { createHash } = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
// Rebuild consensus from committed, operation-bound messages. No in-memory vote
// can survive a crash without its corresponding durable Agent response.
const naturalConsensusMethods = {
v4NaturalConsensus(group, controller, threadRootId) {
const messages = this.state().messages.filter(message => (
message.groupId === group.id && message.threadRootId === threadRootId
&& message.role === 'agent'
&& this.v4NaturalMessageMatchesBinding(message, controller)
)).sort((a, b) => (a.turnSeq || 0) - (b.turnSeq || 0))
let candidate = null
const files = new Map()
for (const message of messages) {
const artifactIds = message.trace?.context?.outcomeRefs?.artifactIds || []
for (const artifactId of artifactIds) {
const { artifact, contentHash } = this.v4ArtifactIdentity(artifactId)
if (artifact.locationRef?.kind === 'workspace-relative') {
if (candidate?.files.some(file => file.path === artifact.locationRef.path
&& file.contentHash !== contentHash)) candidate = null
files.set(artifact.locationRef.path, { path: artifact.locationRef.path,
artifactId, contentHash, size: artifact.contentRef.size })
}
}
if (message.trace?.phase !== 'discussion') continue
const decision = message.trace?.context?.taskDecision
if (!decision) continue
if (decision.status !== 'completed') {
candidate = null
continue
}
if (decision.reviewedVersion) {
if (candidate && decision.reviewedVersion === candidate.version) {
candidate.approvedKinds.add(message.agentKind)
}
continue
}
// An unbound confirmation cannot replace the version being reviewed.
// Revisions must first withdraw it with a non-completed decision.
if (candidate) continue
const contribution = this.v4NaturalMessageContent(message)
if (contribution.partial || !contribution.text.trim()) {
candidate = null
continue
}
const candidateFiles = [...files.values()]
candidate = {
version: hashValue({ messageId: message.id, text: contribution.text, artifactIds, files: candidateFiles }),
message,
text: contribution.text,
artifactIds,
files: candidateFiles,
approvedKinds: new Set([message.agentKind]),
}
}
// Votes refer to captured bytes, not just a filename. Workspace edits made
// outside the discussion must not inherit approval of the previous bytes.
if (candidate?.files.length) {
try {
const root = fs.realpathSync(group.workdir)
for (const file of candidate.files) {
const filename = fs.realpathSync(path.resolve(root, file.path))
const relative = path.relative(root, filename)
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`)
|| path.isAbsolute(relative) || fs.statSync(filename).size !== file.size
|| createHash('sha256').update(fs.readFileSync(filename)).digest('hex') !== file.contentHash) return null
}
} catch { return null }
}
return candidate
},
v4NaturalConsensusPrompt(group, controller, threadRootId) {
const candidate = this.v4NaturalConsensus(group, controller, threadRootId)
if (!candidate) return ''
return [
`Candidate version: ${candidate.version}`,
`Proposed by @${candidate.message.agentKind}. Review this exact complete candidate, including its deliverable files, before accepting.`,
`Candidate deliverables: ${JSON.stringify(candidate.message.trace.context.taskDecision.deliverables)}`,
`Immutable artifact references: ${JSON.stringify(candidate.artifactIds)}`,
`Deliverable file versions (read each file and verify its contents): ${JSON.stringify(candidate.files)}`,
'Complete candidate answer (not a transcript excerpt):',
candidate.text,
`To accept this version, use status completed and reviewedVersion "${candidate.version}" in your receipt. Do not rewrite or replace the candidate while accepting it.`,
'If anything needs changing, use status continue, explain the defect and request the peer who should fix it. This withdraws the candidate and all its approvals; the revised result needs fresh approval from everyone.',
'Do not modify this candidate while accepting it. Missing, stale or implicit approval never counts as acceptance.',
].join('\n\n')
},
async v4NaturalAgentLedOutcome(group, controller, threadRootId, round, activeKinds) {
const messages = this.v4NaturalDiscussionRoundMessages(group, controller, threadRootId, round)
const candidate = this.v4NaturalConsensus(group, controller, threadRootId)
if (candidate) {
const missing = controller.targetKinds.filter(kind => !candidate.approvedKinds.has(kind))
if (!missing.length) return { status: 'completed' }
const available = missing.filter(kind => activeKinds.includes(kind))
if (available.length) return { nextKinds: available }
return { status: 'partial' }
}
const decisionMessage = messages.at(-1)
const decision = decisionMessage?.trace?.context?.taskDecision
const route = this.v4NaturalRouteDecision(decision, activeKinds)
if (route.dropped.length) {
this.v4NaturalNotice(group, controller, threadRootId, 'system.autoRouteIgnored',
'Requests for unavailable participants were skipped.',
{ agents: route.dropped.map(kind => this.agentLabel(kind)).join(', ') })
}
if (decision?.status === 'needs-human' && this.requestTaskDecision) {
const response = await this.requestTaskDecision(group, controller, decisionMessage)
if (response?.status === 'approved') return { nextKinds: [decisionMessage.agentKind] }
if (response?.status === 'rejected') {
controller.stopReason = 'human_gate_rejected'
return { status: 'stopped' }
}
}
if (['blocked', 'needs-human'].includes(decision?.status)) {
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 nextKinds = this.v4NaturalNextKinds(group, controller, threadRootId, round, activeKinds)
if (nextKinds.length) return { nextKinds }
// A missing receipt is not approval. Ask the current speaker to finish
// their handoff or propose a concrete candidate instead of inventing votes.
return { nextKinds: [], fallbackKind: activeKinds.includes(decisionMessage?.agentKind)
? decisionMessage.agentKind : activeKinds[0] }
},
}
module.exports = { naturalConsensusMethods }
@@ -1,239 +0,0 @@
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: [], dropped: [] }
let parsed
try { parsed = parseLenientTaskDecision(decision) }
catch { return { status: 'none', kinds: [], dropped: [] } }
const requested = parsed.nextKinds || []
// 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 = requested.filter(kind => participants.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)
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,
))
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(', ') })
}
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'
})
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 }
const decision = ownerMessage?.trace?.context?.taskDecision
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') {
// 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 }
if (response?.status === 'rejected') {
controller.stopReason = 'human_gate_rejected'
return { status: 'stopped' }
}
}
this.addMessage(group.id, 'system', decision.reason, '', threadRootId, {
key: decision.status === 'needs-human' ? 'system.autoTaskNeedsHuman' : 'system.autoTaskBlocked',
params: { reason: decision.reason },
})
return { status: 'partial' }
},
v4NaturalNextKinds(group, controller, threadRootId, round, activeKinds) {
const current = this.v4NaturalDiscussionRoundMessages(group, controller, threadRootId, round)
const selected = new Set(current.flatMap(message => (
this.v4NaturalRouteDecision(message.trace?.context?.taskDecision, activeKinds).kinds
.filter(kind => kind !== message.agentKind)
)))
if (!selected.size) return []
return [...selected]
},
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
|| message.role !== 'agent' || message.agentKind !== owner
|| message.trace?.phase !== 'discussion'
|| !this.v4NaturalMessageMatchesBinding(message, controller)) continue
const decision = message.trace?.context?.taskDecision
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
},
v4NaturalDiscussionIsRepeating(group, controller, threadRootId, round, activeKinds) {
const window = Math.max(4, activeKinds.length * 2)
if (round < window + 2) return false
const firstRound = round - window + 1
const previous = new Map()
const observedRounds = new Set()
const messages = this.state().messages
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index]
const messageRound = Number(message.trace?.round) || 0
if (message.groupId !== group.id || message.threadRootId !== threadRootId
|| message.role !== 'agent' || message.trace?.phase !== 'discussion'
|| message.trace?.runId !== controller.runId || messageRound < 2
|| messageRound > round || !this.v4NaturalMessageMatchesBinding(message, controller)) continue
const contribution = this.v4NaturalMessageContent(message)
if (contribution.partial) return false
const text = contribution.text
.replace(/@[A-Za-z0-9][A-Za-z0-9_-]*/gu, '')
.replace(/\s+/gu, ' ').trim()
const files = []
for (const artifactId of message.trace?.context?.outcomeRefs?.artifactIds || []) {
try {
const { artifact, contentHash } = this.v4ArtifactIdentity(artifactId)
if (artifact.locationRef?.kind === 'workspace-relative') {
files.push([artifact.locationRef.path, contentHash])
}
} catch { return false }
}
const content = JSON.stringify([text, message.trace?.context?.taskDecision?.reviewedVersion || '', files.sort()])
if (previous.has(message.agentKind) && previous.get(message.agentKind) !== content) return false
if (messageRound >= firstRound) {
observedRounds.add(messageRound)
previous.set(message.agentKind, content)
} else {
previous.delete(message.agentKind)
if (observedRounds.size === window && previous.size === 0) return true
}
}
return false
},
v4NaturalStopRepeatingDiscussion(group, controller, threadRootId, round, activeKinds) {
if (!this.v4NaturalDiscussionIsRepeating(group, controller, threadRootId, round, activeKinds)) return false
this.addMessage(
group.id, 'system',
'Automatic discussion stopped because repeated handoffs produced no new contribution.',
'', threadRootId, { key: 'system.autoDiscussionStalled' },
)
return true
},
}
module.exports = { v4NaturalDecisionMethods }
@@ -1,557 +0,0 @@
// 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 fullOpening = controller.discussionStyle === 'agent-led' && controller.currentRound === 2
? entries.filter(entry => entry.phase === 'proposal') : null
const packed = fullOpening ? {
text: fullOpening.map(entry => `Round 1 - @${entry.agentKind} [${entry.id}]\n${entry.text}`).join('\n\n'),
completeMessageIds: fullOpening.filter(entry => !entry.partial).map(entry => entry.id),
} : 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: group.allowWrite === true ? kind : '',
ownerKind: options.ownerKind,
allowRouting: options.allowRouting === true,
consensusText: options.consensusText || '',
})
},
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, 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.v4NaturalAgentLedOutcome(group, controller, threadRootId, round, activeKinds)
if (outcome.status) return outcome.status
nextKinds = outcome.nextKinds.length ? outcome.nextKinds : [outcome.fallbackKind]
if (!controller.unlimitedRounds && round >= (controller.maxRounds || 6)) {
addRoundLimitNotice()
return 'round-limit'
}
round += 1
}
} else {
round = 2
nextKinds = [activeKinds[0]]
}
while (!controller.signal.aborted) {
controller.currentRound = round
const kind = nextKinds[0]
const remainingKinds = nextKinds.slice(1)
const candidate = this.v4NaturalConsensus(group, controller, threadRootId)
const turnWriterKind = !candidate && group.allowWrite === true && this.canWriteWorkspace(kind)
? kind : ''
const dispatch = await this.runV4Phase(group, controller, threadRootId, context, {
phase: 'discussion',
activeKinds: [kind],
targetKinds,
participantKinds: activeKinds,
writerKind: turnWriterKind,
batchId,
snapshot,
snapshotRecord,
snapshotHash,
slots,
receiptRecords,
challengeBindings: null,
coordinationPlan: null,
workReceipts: [],
operationIds: this.v4NaturalDiscussionOperationIds(
controller, slots, [kind], 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,
writerKind: turnWriterKind,
consensusText: this.v4NaturalConsensusPrompt(group, controller, threadRootId),
},
),
})
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.v4NaturalAgentLedOutcome(group, controller, threadRootId, round, activeKinds)
if (outcome.status) return outcome.status
// A new candidate starts its own confirmation queue. An objection
// cancels pending approvals; otherwise preserve outstanding invitations.
nextKinds = this.v4NaturalConsensus(group, controller, threadRootId) || candidate
? outcome.nextKinds
: [...new Set([...outcome.nextKinds, ...remainingKinds])]
nextKinds = nextKinds.filter(agentKind => activeKinds.includes(agentKind))
if (!nextKinds.length) nextKinds = [outcome.fallbackKind || activeKinds[0]]
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 && ['proposal', 'discussion'].includes(controller.orchestration?.phase)) {
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.v4NaturalRecoveredPhaseResult(group, controller, threadRootId,
controller.orchestration.phase, 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: controller.orchestration.phase, 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 Agent 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')
if (this.completeHumanGateContinuation?.(controller.runId, gate.gateId, 'completed') !== true
&& this.hasRunLedger()) throw new Error('LOCAL_RUN_PERSIST_FAILED')
input.resumedGate = { ...binding, gateId: gate.gateId, type: 'retry',
status: gate.status, optionId: gate.optionId, request, used: true }
}
}
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 (!gate.used && 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 }
@@ -1,443 +0,0 @@
// 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 }
@@ -1,313 +0,0 @@
// 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 }
@@ -1,419 +0,0 @@
// 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 }
@@ -1,403 +0,0 @@
// 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 }
@@ -1,604 +0,0 @@
// 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 }
@@ -1,965 +0,0 @@
// 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
&& controller.discussionStyle !== 'agent-led'
&& (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 }
@@ -1,875 +0,0 @@
// 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 && ((this.naturalAgentResponses && this.canWriteWorkspace(slot.agentKind))
|| (slot.agentKind === writerKind && phase === 'synthesis'))
? '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 || current?.discussionWriterKind || '' : ''
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: slot.permission,
singleWriterKind: this.naturalAgentResponses ? '' : writerKind,
independentProposal: this.naturalAgentResponses,
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: slot.permission,
singleWriterKind: this.naturalAgentResponses ? '' : 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()
const error = item.error || item.invocation?.error
if (error?.runTrace) {
this.recordAgentInterruption(group.id, item.kind, error, threadRootId,
controller.stopReason === 'shutdown' ? 'interrupted' : 'stopped')
}
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 }
@@ -1,848 +0,0 @@
// 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())
} else if (error?.runTrace) {
this.recordAgentInterruption(group.id, kind, error, threadRootId,
controller.stopReason === 'shutdown' ? 'interrupted' : 'stopped')
}
}
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()
const error = invocation.error
if (error?.runTrace) {
this.recordAgentInterruption(group.id, kind, error, threadRootId,
controller.stopReason === 'shutdown' ? 'interrupted' : 'stopped')
}
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()
const error = item.error || item.invocation?.error
if (controller.signal.aborted && error?.runTrace) {
this.recordAgentInterruption(group.id, item.kind, error, threadRootId,
controller.stopReason === 'shutdown' ? 'interrupted' : 'stopped')
}
}
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 }
Loaded 100 of 358 files, more files were not shown because too many files have changed in this diff. Show more