mirror of
https://github.com/Ryder-Sun/Meldwork.git
synced 2026-11-07 17:52:11 -10:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f68669e93e | ||
|
|
4b40d4d56b | ||
|
|
c528e733fb | ||
|
|
6722acbe8a | ||
|
|
5180931d0a | ||
|
|
32797eeb7b | ||
|
|
4f8581ba23 | ||
|
|
159ff767fa | ||
|
|
524009f2ea | ||
|
|
5fef68fc4b | ||
|
|
21a85b682c | ||
|
|
a54e84a208 | ||
|
|
e68699e61f | ||
|
|
540a96fa5e | ||
|
|
5efe559c03 | ||
|
|
c6cb5f108d | ||
|
|
a165ed3019 | ||
|
|
092f38bd37 | ||
|
|
71715ed967 | ||
|
|
7752489936 | ||
|
|
da0108abb1 | ||
|
|
2ff1884961 | ||
|
|
97cac8d185 | ||
|
|
5c1680d0ab | ||
|
|
01a1f51464 | ||
|
|
02d922e805 | ||
|
|
0ae3542367 | ||
|
|
21b0d39de4 | ||
|
|
3e1953e77c | ||
|
|
395bd3d51b |
No files matched your search
@@ -1,5 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.3 - 2026-08-18 (Meldwork-V1.0.3 prerelease)
|
||||
|
||||
### Concurrent Conversations
|
||||
|
||||
- Concurrent Responses now treats an empty explicit selection as the whole group, so users can send one message to every group Agent without adding `@` mentions first.
|
||||
- Keeps every group member in the frozen batch. Agents beyond the available Scheduler capacity remain visibly queued instead of being removed from the request.
|
||||
- Preserves valid visible proposals when a CLI omits or malforms its proposal receipt, while later Auto Discussion phases retain their strict structured-receipt boundary.
|
||||
|
||||
### Conversation Experience
|
||||
|
||||
- Refines user-message metadata, Agent logos, inline Agent/Skill/knowledge mentions, Markdown rendering, mention-menu tracking, and narrow-layout wrapping.
|
||||
- Returns newly sent conversations to the active Agent loading state and keeps the animated return-to-bottom control for readers who have scrolled up.
|
||||
- Removes the special No round limit composer background, border, shadow, and container breathing effect. The infinity status cue and real collaboration phase feedback remain available.
|
||||
|
||||
### Stop Durability
|
||||
|
||||
- Persists completed and streamed concurrent Agent replies when the user stops a batch instead of removing already visible work.
|
||||
- Records stopped Agent attempts as interrupted, rejects late post-stop results, and resumes the stable batch commit idempotently after restart.
|
||||
|
||||
### Packaging And Verification
|
||||
|
||||
- Sets the frontend and desktop package version to `0.1.3` and publishes Apple silicon DMG and ZIP prerelease artifacts with a matching SHA-256 manifest.
|
||||
- Frontend tests: 306/306 passed. Desktop tests: 1338/1338 passed.
|
||||
- Web and Electron renderer builds, Electron `pack`, packaged acceptance, and `git diff --check` passed before release packaging.
|
||||
- The prerelease remains ad-hoc signed, without Apple Developer ID signing or notarization, and may require Open Anyway on first launch.
|
||||
|
||||
## 0.1.2 - 2026-08-18 (Meldwork-V1.0.2 prerelease)
|
||||
|
||||
### Collaboration Harness V4
|
||||
|
||||
@@ -21,142 +21,133 @@
|
||||
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-MHumble/Meldwork/releases/download/Meldwork-V1.0.2/Meldwork-0.1.2-arm64.dmg"><strong>Download Meldwork V1.0.2 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>
|
||||
|
||||
## The missing layer
|
||||
## Who it is for
|
||||
|
||||
Agent supply is growing quickly. The organization around it is not.
|
||||
Use Meldwork when you:
|
||||
|
||||
Today, people still choose every Agent, move every piece of context, explain every role, reconcile conflicting answers, and carry the final accountability. Parallel managers improve throughput. Agent frameworks let developers program workflows. Single-Agent products improve one worker.
|
||||
- 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.
|
||||
|
||||
Meldwork is focused on the work between them:
|
||||
## Workflow
|
||||
|
||||
- one shared goal and acceptance boundary;
|
||||
- independent viewpoints instead of hidden routing;
|
||||
- explicit authority, responsibility, handoffs, and stopping conditions;
|
||||
- artifacts and evidence that survive Agent changes;
|
||||
- a human-controlled adoption decision.
|
||||
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.
|
||||
|
||||
## What works today
|
||||
## Collaboration modes
|
||||
|
||||
- **Supported local Agent CLIs:** detect, inspect, and invoke approved Agents without exposing unrestricted shell execution to the renderer.
|
||||
- **Persistent direct work:** keep local conversation history, attachments, permissions, compatible native sessions, and sanitized execution details together.
|
||||
- **Concurrent responses:** send one message to multiple selected Agents, freeze the same task context before execution, and publish their independent replies as one stable batch.
|
||||
- **Agent-negotiated collaboration:** start with independent proposals, let the selected Agents challenge proposals and unanimously agree one responsibility graph, execute distinct work packages, integrate through one writer, and verify through different Agents.
|
||||
- **Inspectable execution:** preserve bounded run events, warnings, completion states, checkpoints, and recovery actions without exposing credentials or private reasoning.
|
||||
- **Local control:** keep Meldwork conversations and orchestration state on the computer; workspace writes remain opt-in.
|
||||
|
||||
Capability still depends on the selected Agent, installed version, authentication state, Provider, and declared tool or attachment support.
|
||||
|
||||
## What Meldwork is building
|
||||
|
||||
The collaboration mechanism is not a hidden preset A → B → C assignment. The user selects the participants; those Agents expose the solution space, negotiate responsibilities, and execute inside Harness-enforced context, permission, budget, recovery, and evidence boundaries.
|
||||
|
||||
~~~mermaid
|
||||
flowchart LR
|
||||
G["Goal + outcome contract"] --> P["Independent proposals"]
|
||||
P --> C["Visible challenge"]
|
||||
C --> B["Role claims + team bids"]
|
||||
B --> R["Responsibility contract"]
|
||||
R --> X["Bounded execution"]
|
||||
X --> E["Artifact + evidence"]
|
||||
E --> H["Human adoption"]
|
||||
H --> O["Outcome receipt"]
|
||||
O -. "Agent fit + team fit" .-> P
|
||||
~~~
|
||||
|
||||
The selected-Agent proposal, challenge, responsibility, work, integration, and verification loop is available today. Automatic participant selection from a broader roster, remote Agents, enterprise governance, and outcome-driven reputation remain future direction.
|
||||
|
||||
## The Harness
|
||||
|
||||
The Harness is the control plane that keeps conversations, context, adapters, streams, and durable state aligned in one workspace. It is what makes a run continuous across sessions, timeouts, recovery, and Agent changes.
|
||||
|
||||
<p align="center">
|
||||
<img src="frontend/public/logos/Harness-readme.png" alt="Meldwork Harness" width="100%">
|
||||
</p>
|
||||
|
||||
## Where Meldwork fits
|
||||
|
||||
This is a comparison of working models, not a feature-scorecard claiming Meldwork should replace every tool.
|
||||
|
||||
| Approach | Examples | Best at | Main tradeoff | Meldwork's choice |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| One Agent CLI or AI IDE | Claude Code, Codex, Cursor, Windsurf | Deep work inside one Agent or product ecosystem | Moving to another Agent still requires a manual handoff | Preserve work across heterogeneous Agents |
|
||||
| Parallel coding-Agent managers | Superset, Vibe Kanban, Nimbalyst, Conductor | Running multiple coding tasks and isolated workspaces in parallel | Primarily optimized for throughput and code delivery | Optimize proposal quality, responsibility, evidence, and adoption |
|
||||
| Cloud Agent control planes | GitHub Agent HQ, Warp Oz, Devin, Factory, OpenHands Cloud | Dispatching and observing remote jobs, sandboxes, and Agent fleets | Work is organized around cloud or platform infrastructure | Start local-first and bring your existing Agents |
|
||||
| Programmable Agent frameworks | LangGraph, CrewAI, AutoGen, Agents SDK, ADK | Building custom graphs, roles, routing, and automation | Developers must design and maintain the orchestration | Deliver an organization and acceptance experience to users |
|
||||
| Multiple terminals and scripts | tmux, shell scripts, manual copy and paste | Maximum access to native Agent capabilities | The user becomes the router, context bus, and final judge | Make the work, boundaries, and decisions durable |
|
||||
| **Meldwork** | Supported local Agent CLIs; remote supply is direction | Concurrent independent responses and selected-Agent proposal, negotiation, responsibility, evidence, and verification | Local single-user preview today; participants are still selected by the user | Build the organization layer for the Agent workforce |
|
||||
|
||||
If one Agent and one work surface are enough, use the native tool. Use Meldwork when handoff, independent judgment, authority, evidence, and acceptance become part of the problem.
|
||||
|
||||
## See the current work cell
|
||||
|
||||
| Local Agent discovery | Direct multimodal work | Concurrent multi-Agent work |
|
||||
| Mode | What happens | Best for |
|
||||
| --- | --- | --- |
|
||||
| [](assets/meldwork-agent-discovery.png) | [](assets/meldwork-direct-multimodal.png) | [](assets/meldwork-group-collaboration.png) |
|
||||
| See which supported Agents are ready. | Keep prompts, files, permissions, and compatible sessions together. | Run concurrent replies or an Agent-negotiated collaboration with the participants you selected. |
|
||||
| **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. |
|
||||
|
||||
## Connected Agents
|
||||
Participants are always selected by the user. Automatic selection from a larger roster is outside the current preview.
|
||||
|
||||
Current integrations include:
|
||||
## How Meldwork differs
|
||||
|
||||
**Codex · Hermes · OpenClaw · WorkBuddy · Kimi Code · MiMo Code · Claude Code · Gemini CLI · OpenCode · Qwen Code**
|
||||
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.
|
||||
|
||||
Specialized review:
|
||||
| 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. |
|
||||
|
||||
**OpenCodeReview**
|
||||
## See it in action
|
||||
|
||||
Availability depends on installation, version, authentication, Provider, and declared capabilities. Each Agent keeps its own tools, session behavior, and permission model.
|
||||
<table>
|
||||
<tr>
|
||||
<th>Local Agent discovery</th>
|
||||
<th>Multi-agent review</th>
|
||||
<th>Direct multimodal work</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<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>
|
||||
|
||||
## Install
|
||||
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.
|
||||
|
||||
The current release-validated target is Apple silicon macOS.
|
||||
## Supported local Agent CLIs
|
||||
|
||||
1. Open the [Meldwork V1.0.2 prerelease](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.2).
|
||||
2. Download Meldwork-0.1.2-arm64.dmg.
|
||||
3. Drag Meldwork into Applications and try to open it once.
|
||||
4. This prerelease is ad-hoc signed, not signed with an Apple Developer ID, and not Apple-notarized. If macOS blocks it, open **System Settings → Privacy & Security**, find the Meldwork warning, choose **Open Anyway**, and confirm.
|
||||
5. Connect a supported Agent CLI already installed on the computer, or configure an independent Provider profile where supported.
|
||||
Meldwork detects and invokes an installed command when its adapter and the CLI version are compatible:
|
||||
|
||||
Use only artifacts downloaded from the official Release.
|
||||
<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>
|
||||
|
||||
<details>
|
||||
<summary><strong>Build from source</strong></summary>
|
||||
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.
|
||||
|
||||
Requires Node.js 22.12 or newer and npm.
|
||||
## Quick start
|
||||
|
||||
~~~bash
|
||||
### Apple silicon macOS preview
|
||||
|
||||
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.
|
||||
|
||||
```bash
|
||||
npm --prefix frontend ci
|
||||
npm --prefix desktop ci
|
||||
npm --prefix desktop run dev
|
||||
~~~
|
||||
```
|
||||
|
||||
Run the repository checks before distributing a build:
|
||||
Before distributing a build:
|
||||
|
||||
~~~bash
|
||||
```bash
|
||||
npm --prefix frontend test
|
||||
npm --prefix frontend run build
|
||||
npm --prefix frontend run build:desktop
|
||||
npm --prefix desktop test
|
||||
~~~
|
||||
```
|
||||
|
||||
</details>
|
||||
## Current boundary
|
||||
|
||||
## Trust boundaries
|
||||
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.
|
||||
|
||||
- Conversations and Meldwork orchestration state stay local.
|
||||
- Model requests still follow the Agent and Provider you choose; local-first does not mean every model runs offline.
|
||||
- Credentials use operating-system-backed secure storage where supported.
|
||||
- Workspace writes are explicit and opt-in.
|
||||
- Raw chain-of-thought, executable paths, secrets, and unrestricted command output are not exposed to the renderer.
|
||||
- Cloud, Channel, automatic participant selection, enterprise governance, and an Outcome Network are not current release promises.
|
||||
## Docs
|
||||
|
||||
Read the [architecture](architecture.md) and [Agent Connector contract](docs/agent-connector-sdk.md) for the public technical boundary.
|
||||
- [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 source-available under the [Meldwork Non-Commercial Source License](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).
|
||||
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).
|
||||
+90
-99
@@ -21,142 +21,133 @@
|
||||
当前预览版是一个面向已支持本地 Agent CLI 的本地工作单元。它把参与者、上下文、协作边界、运行状态和人类复核放在同一个工作空间里。已选 Agent 现在可以并发回复,也可以围绕同一目标先独立提案、再协商职责、分工执行、单写者整合并由其他 Agent 独立复核。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Ryder-MHumble/Meldwork/releases/download/Meldwork-V1.0.2/Meldwork-0.1.2-arm64.dmg"><strong>下载 Meldwork V1.0.2 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>
|
||||
|
||||
## Agent workforce 缺少的那一层
|
||||
## 适合谁
|
||||
|
||||
Agent 的供给正在快速增长,但围绕它们工作的组织能力并没有跟上。
|
||||
当你有以下需求时,Meldwork 更适合:
|
||||
|
||||
今天,人类仍然需要自己选 Agent、搬运上下文、解释角色、处理冲突答案,并承担最终责任。并行管理器优化吞吐量,多 Agent 框架让开发者编写 Workflow,单 Agent 产品优化一个 Worker。
|
||||
- 已经安装多个本地 Agent CLI,希望让同一任务跨 Agent 协作;
|
||||
- 需要在代码评审、研究或产品分析中获得独立判断,再选择方案;
|
||||
- 需要在写入工作区前检查可追溯的运行轨迹、证据并保留人工复核。
|
||||
|
||||
Meldwork 关注的是中间那层工作:
|
||||
## 工作流程
|
||||
|
||||
- 一个共享 Goal 和明确验收边界;
|
||||
- 独立判断,而不是隐藏路由;
|
||||
- 明确的权限、责任、交接和停止条件;
|
||||
- 不随 Agent 切换而丢失的 Artifact 与 Evidence;
|
||||
- 由人类掌握的最终采用决定。
|
||||
1. **选择**本地 Agent 和参与者。
|
||||
2. **限定**目标、工作目录、上下文和权限。
|
||||
3. **运行**直接会话、并发回复或 Auto Discussion V4。
|
||||
4. **复核并采用**结果、证据和待处理的人工审批门(Human Gate),再写入工作区。
|
||||
|
||||
## 当前已经可用
|
||||
## 协作模式
|
||||
|
||||
- **支持的本地 Agent CLI**:检测、检查并受控调用已批准的 Agent,不把不受限的 Shell 暴露给 Renderer。
|
||||
- **持续的直接工作**:把本地历史、附件、权限、兼容的原生 Session 和脱敏执行详情放在一起,尽量不让上下文断线。
|
||||
- **并发回复**:一条消息同时发给多个已选 Agent,执行前冻结同一份任务上下文,并以稳定批次公布各自独立回复。
|
||||
- **Agent 自主协商协作**:先独立提案,再互相质询并全员同意一份职责图,随后执行不同工作包,由唯一写入者整合,并由不同 Agent 独立复核。
|
||||
- **可检查执行**:保留有界运行事件、警告、终态、检查点和恢复动作,不暴露凭据或私有推理。
|
||||
- **本地控制**:Meldwork 的会话和编排状态都留在电脑上;工作目录写入必须主动开启。
|
||||
|
||||
具体能力仍取决于所选 Agent、本机版本、认证状态、Provider 和声明的工具/附件支持。
|
||||
|
||||
## Meldwork 正在构建什么
|
||||
|
||||
当前协作机制不是 Harness 暗中预设的 A → B → C 分配链。用户选定参与者后,Agent 先展开解空间、自主协商职责,再在 Harness 管理的上下文、权限、预算、恢复和证据边界内执行。
|
||||
|
||||
~~~mermaid
|
||||
flowchart LR
|
||||
G["目标与验收契约"] --> P["独立竞案"]
|
||||
P --> C["公开质询"]
|
||||
C --> B["角色声明与团队竞案"]
|
||||
B --> R["责任契约"]
|
||||
R --> X["有界执行"]
|
||||
X --> E["产物与证据"]
|
||||
E --> H["人类采用"]
|
||||
H --> O["结果回执"]
|
||||
O -. "Agent Fit + Team Fit" .-> P
|
||||
~~~
|
||||
|
||||
已选 Agent 的独立提案、交叉质询、职责协商、分工执行、整合和复核现在已经可用。从更大候选池自动选人组队、远程 Agent、企业治理和 Outcome 驱动的声誉系统仍是未来方向。
|
||||
|
||||
## Harness
|
||||
|
||||
Harness 是把会话、上下文、适配器、流和持久化状态拴在一起的控制平面。它让一次任务可以跨会话、超时、恢复和 Agent 切换继续下去。
|
||||
|
||||
<p align="center">
|
||||
<img src="frontend/public/logos/Harness-readme.png" alt="Meldwork Harness" width="100%">
|
||||
</p>
|
||||
|
||||
## Meldwork 在哪里
|
||||
|
||||
这张表比较的是不同工作范式,不是一张声称 Meldwork 应当替代所有工具的功能勾选表。
|
||||
|
||||
| 范式 | 例子 | 最擅长什么 | 主要代价 | Meldwork 的选择 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 单 Agent CLI / AI IDE | Claude Code、Codex、Cursor、Windsurf | 在一个 Agent 或产品生态内完成深度工作 | 换 Agent 时仍需手工交接 | 保留跨异构 Agent 的工作 |
|
||||
| 并行 Coding-Agent 工作台 | Superset、Vibe Kanban、Nimbalyst、Conductor | 并行运行多个 Coding 任务和隔离工作区 | 主要优化吞吐量和代码交付 | 优化方案质量、责任、证据和采用 |
|
||||
| Cloud Agent 控制面 | GitHub Agent HQ、Warp Oz、Devin、Factory、OpenHands Cloud | 派发和观察远程 Job、Sandbox 与 Agent Fleet | 工作围绕云基础设施和平台边界 | 先从 Local-first 和 BYO Agent 切入 |
|
||||
| 可编程多 Agent 框架 | LangGraph、CrewAI、AutoGen、Agents SDK、ADK | 开发者自定义 Graph、角色、路由和自动化 | 需要开发者设计并维护编排 | 面向使用者交付组织与验收体验 |
|
||||
| 多终端与脚本 | tmux、Shell、手工复制粘贴 | 最大化原生 Agent 访问权 | 人类成为 Router、Context Bus 和最终裁决者 | 让工作、边界和决定保持持续 |
|
||||
| **Meldwork** | 已支持本地 Agent CLI;远程供给属于方向 | 并发独立回复,以及已选 Agent 之间的提案、协商、责任、Evidence 和复核 | 当前仍是本地单用户预览;参与者仍由用户选定 | 构建 Agent workforce 的组织层 |
|
||||
|
||||
如果一个 Agent 和一个工作面已经足够,直接使用原生工具。只有当交接、独立判断、权限、证据和验收成为问题时,才需要 Meldwork。
|
||||
|
||||
## 看看当前 Work Cell
|
||||
|
||||
| 本地 Agent 检测 | 单聊中的多模态工作 | 并发多 Agent 协作 |
|
||||
| 模式 | 运行方式 | 适合场景 |
|
||||
| --- | --- | --- |
|
||||
| [](assets/meldwork-agent-discovery.png) | [](assets/meldwork-direct-multimodal.png) | [](assets/meldwork-group-collaboration.png) |
|
||||
| 查看哪些受支持 Agent 已就绪。 | Prompt、文件、权限和兼容 Session 留在一起。 | 让已选 Agent 并发回复,或进入由 Agent 自主协商职责的协作流程。 |
|
||||
| **直接会话** | 一个已选 Agent 在适配器支持时保持对话和原生会话。 | 使用单个 Agent 完成聚焦工作。 |
|
||||
| **并发回复** | 已选 Agent 收到同一份冻结任务快照,并按稳定顺序返回独立回复。 | 在选择方案前比较不同路径。 |
|
||||
| **Auto Discussion V4** | 已选 Agent 先提案、质询并协商责任,按依赖执行工作,再独立复核结果。 | 需要明确责任和复核的多轮工作。 |
|
||||
|
||||
## 当前可连接的 Agent
|
||||
参与者始终由用户选择。从更大候选池自动选人不属于当前预览版。
|
||||
|
||||
当前集成包括:
|
||||
## Meldwork 与相邻产品的区别
|
||||
|
||||
**Codex · Hermes · OpenClaw · WorkBuddy · Kimi Code · MiMo Code · Claude Code · Gemini CLI · OpenCode · Qwen Code**
|
||||
Meldwork 是面向多 Agent 评审与决策可追溯性的本地优先 AI Agent 工作空间,不是终端、云端 Agent Fleet、通信网络或可编程编排框架。它把你已经在使用的本地 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,并保持本地数据边界。 |
|
||||
|
||||
**OpenCodeReview**
|
||||
## 运行示例
|
||||
|
||||
能否使用取决于安装、版本、认证、Provider 和声明的能力。每个 Agent 保留自己的工具、Session 行为和权限模型。
|
||||
<table>
|
||||
<tr>
|
||||
<th>本地 Agent 检测</th>
|
||||
<th>多 Agent 评审</th>
|
||||
<th>直接会话中的多模态工作</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<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>
|
||||
|
||||
## 安装
|
||||
示例:把同一份变更评审上下文交给 Codex、Claude Code 和 Gemini CLI,比较各自的独立发现,再只采用你批准且有证据支持的结果。
|
||||
|
||||
当前经过发布验证的目标是 Apple 芯片 macOS。
|
||||
## 支持的本地 Agent CLI
|
||||
|
||||
1. 打开 [Meldwork V1.0.2 预发布页](https://github.com/Ryder-MHumble/Meldwork/releases/tag/Meldwork-V1.0.2)。
|
||||
2. 下载 Meldwork-0.1.2-arm64.dmg。
|
||||
3. 将 Meldwork 拖入“应用程序”,然后先尝试打开一次。
|
||||
4. 当前预发布版使用临时签名,没有 Apple Developer ID 签名,也尚未经过 Apple 公证。如果 macOS 阻止启动,请打开“系统设置 → 隐私与安全性”,找到 Meldwork 提示,点击“仍要打开”并确认。
|
||||
5. 连接电脑上已安装的受支持 Agent CLI,或在兼容情况下单独配置 Provider。
|
||||
当适配器和 CLI 版本兼容时,Meldwork 会检测并调用已安装的命令:
|
||||
|
||||
请只使用官方 Release 下载的产物。
|
||||
<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>
|
||||
|
||||
<details>
|
||||
<summary><strong>从源码运行</strong></summary>
|
||||
已批准的 Agent Connector 可通过 [Agent Connector SDK](docs/agent-connector-sdk.md) 接入;自定义可执行 Agent 使用桌面端的自定义 Agent 入口。完整适配器和能力矩阵见[桌面端指南](desktop/README.md)。
|
||||
|
||||
需要 Node.js 22.12 或更高版本以及 npm。
|
||||
## 快速开始
|
||||
|
||||
~~~bash
|
||||
### 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。
|
||||
|
||||
```bash
|
||||
npm --prefix frontend ci
|
||||
npm --prefix desktop ci
|
||||
npm --prefix desktop run dev
|
||||
~~~
|
||||
```
|
||||
|
||||
分发前运行:
|
||||
分发构建前运行:
|
||||
|
||||
~~~bash
|
||||
```bash
|
||||
npm --prefix frontend test
|
||||
npm --prefix frontend run build
|
||||
npm --prefix frontend run build:desktop
|
||||
npm --prefix desktop test
|
||||
~~~
|
||||
```
|
||||
|
||||
</details>
|
||||
## 当前边界
|
||||
|
||||
## 信任边界
|
||||
Meldwork 是本地 Electron 应用,不是托管式 Agent Fleet,也不是通用 Agent 框架。当前预览版不提供远程、云端或频道 Agent 执行,不提供自动选人组队、企业 SSO/RBAC/治理或 Outcome Network。Local-first 不等于完全离线:选定的 Agent 仍可能把 Prompt、附件或 Skill(技能)发送到其配置的 Provider(模型服务商)。工作区写入是可选的工作流控制,不是操作系统级沙箱。
|
||||
|
||||
- Meldwork 会话和编排状态保存在本地。
|
||||
- 模型请求仍然按照你选择的 Agent 和 Provider 发出;Local-first 不等于所有模型离线运行。
|
||||
- 兼容环境使用操作系统安全存储保存凭据。
|
||||
- 工作目录写入必须显式开启。
|
||||
- Renderer 不暴露 raw chain-of-thought、可执行路径、Secret 或不受限命令输出。
|
||||
- Cloud、Channel、自动选人组队、企业治理和 Outcome Network 不是当前发布承诺。
|
||||
## 文档
|
||||
|
||||
公开技术边界见[架构](architecture.md)和 [Agent Connector 契约](docs/agent-connector-sdk.md)。
|
||||
- [架构与产品边界](architecture.md)
|
||||
- [桌面端设置与 Agent 矩阵](desktop/README.md)
|
||||
- [Agent Connector SDK](docs/agent-connector-sdk.md)
|
||||
- [AI 可发现性索引](docs/ai-discoverability.md)
|
||||
- [贡献指南](CONTRIBUTING.md)
|
||||
- [安全策略](SECURITY.md)
|
||||
|
||||
## 许可证
|
||||
|
||||
Meldwork 采用 [Meldwork 非商用源码许可](LICENSE)。你可以为非商用目的查看、运行、修改和分享;商业使用需要事先书面许可,详见 [COMMERCIAL_USE.md](COMMERCIAL_USE.md)。
|
||||
Meldwork 采用 [Meldwork 非商用源码许可 1.0](LICENSE)。你可以出于非商业目的查看、运行、修改和分享。商业使用需要事先书面许可,详见 [COMMERCIAL_USE.md](COMMERCIAL_USE.md)。
|
||||
+4
-4
@@ -28,7 +28,7 @@ Agent 管理面板提供经过白名单约束的安装入口。安装命令和
|
||||
|
||||
## 首次启动与本地 Agent 检测
|
||||
|
||||
V1.0.2 GitHub 预发布使用 ad-hoc 临时签名,不使用 Apple Developer ID,也不提交 Apple 公证。
|
||||
V1.0.3 GitHub 预发布使用 ad-hoc 临时签名,不使用 Apple Developer ID,也不提交 Apple 公证。
|
||||
如果 macOS 首次启动时拦截候选应用,请打开
|
||||
“系统设置 → 隐私与安全性”,找到 Meldwork 的拦截提示,点击“仍要打开 / Open Anyway”,
|
||||
再确认启动。
|
||||
@@ -108,12 +108,12 @@ Hermes 和 Kimi 的安装脚本来自固定 HTTPS 白名单地址;客户端不
|
||||
- `src/agents/`:Agent 兼容层、路由、运行时、CLI、安装器、连接器
|
||||
- `src/attachments/`:附件、内容存储、图片尺寸校验
|
||||
- `src/collaboration/`:对话、上下文包、产物、任务图
|
||||
- `src/workspace/`:本地工作区与自动调度
|
||||
- `src/workspace/`:本地工作区与自动调度;自动运行器的通用失败、重试和等待语义位于 `local-workspace-auto-runner-support.cjs`
|
||||
- `src/runs/`:运行、预算、事件、调度、日志
|
||||
- `src/knowledge/`:知识库与知识连接器
|
||||
- `src/skills/`:Skill 目录、快照、信任
|
||||
- `src/security/`:私有文件与敏感信息脱敏
|
||||
- `test/`:按同名领域镜像组织测试,`support/` 放测试辅助代码
|
||||
- `test/`:按同名领域镜像组织测试,`support/` 放测试辅助代码;测试发现与 suite 选择位于 `scripts/test-discovery.cjs`
|
||||
|
||||
## 本地开发
|
||||
|
||||
@@ -137,7 +137,7 @@ npm run dist
|
||||
npm run dist:public
|
||||
```
|
||||
|
||||
`pack` 用于生成未打包应用并验证 Electron 产物;`dist` 生成 V1.0.2 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 与
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meldwork-desktop",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meldwork-desktop",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.23.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "meldwork-desktop",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"private": true,
|
||||
"description": "A local-first desktop workspace for multi-Agent collaboration",
|
||||
"author": "Ryder-MHumble",
|
||||
@@ -16,6 +16,10 @@
|
||||
"eval:deterministic": "node scripts/run-evals.cjs --suite deterministic",
|
||||
"eval:provider": "node scripts/run-evals.cjs --suite provider",
|
||||
"test": "node scripts/run-tests.cjs",
|
||||
"test:agents": "node scripts/run-tests.cjs --suite agents",
|
||||
"test:runs": "node scripts/run-tests.cjs --suite runs",
|
||||
"test:workspace": "node scripts/run-tests.cjs --suite workspace",
|
||||
"test:security": "node scripts/run-tests.cjs --suite security",
|
||||
"pack": "npm run build:frontend && electron-builder --dir",
|
||||
"dist": "npm run build:frontend && electron-builder --mac dmg zip",
|
||||
"dist:public": "node scripts/public-release-preflight.cjs && npm run build:frontend && electron-builder --config electron-builder.public.cjs --mac dmg zip"
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { discoverTestFiles } = require('./test-discovery.cjs')
|
||||
|
||||
function testFiles(directory) {
|
||||
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const filename = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) return testFiles(filename)
|
||||
return entry.isFile() && entry.name.endsWith('.test.cjs') ? [filename] : []
|
||||
})
|
||||
function option(name, fallback) {
|
||||
const index = process.argv.indexOf(name)
|
||||
return index === -1 ? fallback : process.argv[index + 1] || fallback
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, ['--test', '--test-concurrency=2', ...testFiles('test').sort()], {
|
||||
const suite = option('--suite', 'all')
|
||||
const result = spawnSync(process.execPath, [
|
||||
'--test',
|
||||
'--test-concurrency=2',
|
||||
...discoverTestFiles({ suite }),
|
||||
], {
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const TEST_SUITES = Object.freeze({
|
||||
agents: ['agents'],
|
||||
attachments: ['attachments'],
|
||||
collaboration: ['collaboration'],
|
||||
core: ['channels', 'knowledge', 'media', 'providers', 'skills'],
|
||||
runs: ['runs'],
|
||||
security: ['security', 'shell'],
|
||||
workspace: ['workspace', 'gates'],
|
||||
})
|
||||
|
||||
function collectTestFiles(directory) {
|
||||
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const filename = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) return collectTestFiles(filename)
|
||||
return entry.isFile() && entry.name.endsWith('.test.cjs') ? [filename] : []
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeSuite(suite) {
|
||||
const value = String(suite || 'all').trim().toLowerCase()
|
||||
if (value === 'all') return null
|
||||
if (!Object.hasOwn(TEST_SUITES, value)) {
|
||||
throw new Error(`Unknown desktop test suite: ${value}`)
|
||||
}
|
||||
return new Set(TEST_SUITES[value])
|
||||
}
|
||||
|
||||
function testSuiteForFile(filename, directory = 'test') {
|
||||
const relative = path.relative(path.resolve(directory), filename)
|
||||
return relative.split(path.sep)[0] || 'core'
|
||||
}
|
||||
|
||||
function discoverTestFiles({ directory = 'test', suite = 'all' } = {}) {
|
||||
const selectedRoots = normalizeSuite(suite)
|
||||
return collectTestFiles(path.resolve(directory))
|
||||
.filter((filename) => !selectedRoots || selectedRoots.has(testSuiteForFile(filename, directory)))
|
||||
.sort()
|
||||
}
|
||||
|
||||
module.exports = { TEST_SUITES, discoverTestFiles, normalizeSuite, testSuiteForFile }
|
||||
@@ -5,6 +5,7 @@ const AGENT_VERSION_IDENTITIES = Object.freeze({
|
||||
hermes: [/\bhermes(?:\s+agent)?\b/i],
|
||||
openclaw: [/\bopenclaw\b/i],
|
||||
workbuddy: [/\b(?:workbuddy|codebuddy)\b/i],
|
||||
pi: [/\bpi(?:\s+agent)?\b/i],
|
||||
kimi: [/\bkimi(?:\s+code)?\b/i],
|
||||
mimo: [/\bmimo(?:\s+code)?\b/i, /\bmimocode\b/i],
|
||||
claude: [/\bclaude(?:\s+code)?\b/i],
|
||||
@@ -18,6 +19,7 @@ const AGENT_EXECUTABLE_NAMES = Object.freeze({
|
||||
hermes: ['hermes'],
|
||||
openclaw: ['openclaw'],
|
||||
workbuddy: ['codebuddy'],
|
||||
pi: ['pi', 'pi-agent', 'piagent'],
|
||||
kimi: ['kimi'],
|
||||
mimo: ['mimo'],
|
||||
claude: ['claude'],
|
||||
@@ -54,6 +56,9 @@ const AGENT_COMPATIBILITY = Object.freeze({
|
||||
'json', 'plan', 'acceptEdits',
|
||||
]),
|
||||
]),
|
||||
pi: exactProfile('0.84.2', [
|
||||
probe('pi-cli', ['--help'], []),
|
||||
]),
|
||||
kimi: profile('0.19.2', '0.32.0', [
|
||||
probe('kimi-stream', ['--help'], [
|
||||
'--output-format', '--auto', '--session', '--prompt', 'stream-json',
|
||||
|
||||
@@ -339,7 +339,18 @@ class AgentRouter {
|
||||
const explicitReadOnlyTargets = effectiveMode === 'explicit'
|
||||
&& explicitTargets.length > 0
|
||||
&& explicitTargets.every(kind => isCodeReviewAgentKind(kind))
|
||||
const permissionMode = explicitReadOnlyTargets
|
||||
const detected = new Map((Array.isArray(agents) ? agents : []).map(agent => [agent.kind, agent]))
|
||||
const permissionTargets = effectiveMode === 'explicit' ? explicitTargets : approvedKinds
|
||||
const targetWriteSupport = permissionTargets.map((kind) => (
|
||||
agentRuntimeCapabilities(kind, {
|
||||
agent: detected.get(kind),
|
||||
attachmentSupport: this.attachmentSupport(kind),
|
||||
}).permissionModes.includes('workspace-write')
|
||||
))
|
||||
const writePermissionCompatible = effectiveMode === 'explicit'
|
||||
? targetWriteSupport.every(Boolean)
|
||||
: targetWriteSupport.some(Boolean)
|
||||
const permissionMode = explicitReadOnlyTargets || !writePermissionCompatible
|
||||
? 'read-only'
|
||||
: group.allowWrite === true ? 'workspace-write' : 'read-only'
|
||||
const requirements = normalizeRoutingRequirements(input.routingRequirements, {
|
||||
@@ -354,7 +365,6 @@ class AgentRouter {
|
||||
const kind = explicitTargets.find(target => !approvedKinds.includes(target))
|
||||
throw routingError('LOCAL_MESSAGE_TARGET_OUT_OF_GROUP', { kind })
|
||||
}
|
||||
const detected = new Map((Array.isArray(agents) ? agents : []).map(agent => [agent.kind, agent]))
|
||||
const candidates = approvedKinds.map((kind) => evaluateCandidate(
|
||||
detected.get(kind) || { kind, available: false },
|
||||
requirements,
|
||||
|
||||
@@ -79,6 +79,10 @@ const AGENT_RUNTIME_CAPABILITIES = Object.freeze({
|
||||
domains: ['general', 'software-development', 'document-production'],
|
||||
toolClasses: ['filesystem', 'shell'],
|
||||
}),
|
||||
pi: generalProfile({
|
||||
domains: ['general', 'software-development'],
|
||||
toolClasses: ['filesystem', 'shell'],
|
||||
}),
|
||||
kimi: generalProfile({
|
||||
domains: ['general', 'software-development', 'research', 'document-production'],
|
||||
toolClasses: ['filesystem', 'shell'],
|
||||
@@ -206,6 +210,15 @@ function normalizeExternalRunRef(value) {
|
||||
return EXTERNAL_RUN_REF.test(ref) ? ref : ''
|
||||
}
|
||||
|
||||
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)\b|(?:invalid|expired|missing)\b[\s\S]*(?:token|credential|api[ _-]?key|auth))/i
|
||||
.test(text)
|
||||
? text
|
||||
: ''
|
||||
}
|
||||
|
||||
function failureCategory(code) {
|
||||
const value = String(code || '')
|
||||
if (value === 'LOCAL_AGENT_SESSION_INVALID') return 'session'
|
||||
@@ -295,6 +308,10 @@ function requireTerminalAgentResult(result) {
|
||||
failure,
|
||||
)
|
||||
}
|
||||
const authenticationDiagnostic = terminalAuthenticationDiagnostic(normalized.text)
|
||||
if (authenticationDiagnostic) {
|
||||
throw agentRuntimeError('LOCAL_AGENT_AUTH_REQUIRED', authenticationDiagnostic)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
@@ -328,4 +345,5 @@ module.exports = {
|
||||
normalizeOutcome,
|
||||
outcomeForAcpStopReason,
|
||||
requireTerminalAgentResult,
|
||||
terminalAuthenticationDiagnostic,
|
||||
}
|
||||
@@ -745,7 +745,7 @@ function publicSessionRef(value, childEnv) {
|
||||
return safe.includes('[redacted]') ? '' : safe
|
||||
}
|
||||
|
||||
async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
|
||||
async function runAcpTurn(runtime, prompt, options, spec, profile) {
|
||||
if (runtime.closed || runtime.failureError) {
|
||||
throw runtime.failureError || agentExecutionError('LOCAL_AGENT_PROCESS_FAILED')
|
||||
}
|
||||
@@ -812,9 +812,6 @@ async function runAcpTurn(runtime, prompt, options, spec, profile, persistent) {
|
||||
|
||||
try {
|
||||
if (!protocolSessionRef) {
|
||||
if (persistent && options.sessionRef) {
|
||||
throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
|
||||
}
|
||||
if (spec.acpSessionStrategy !== 'new' && options.sessionRef) {
|
||||
protocolSessionRef = String(options.sessionRef)
|
||||
await operation(runtime.connection.resumeSession({
|
||||
@@ -955,9 +952,6 @@ async function runPersistentAcpAgent(agent, prompt, workdir, options, spec, prof
|
||||
runtime = null
|
||||
if (options.sessionRef) throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
|
||||
}
|
||||
if (!runtime && options.sessionRef) {
|
||||
throw agentExecutionError('LOCAL_AGENT_SESSION_INVALID')
|
||||
}
|
||||
if (!runtime) {
|
||||
if (shutdownGeneration !== acpShutdownGeneration) {
|
||||
throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
|
||||
@@ -969,7 +963,7 @@ async function runPersistentAcpAgent(agent, prompt, workdir, options, spec, prof
|
||||
}
|
||||
runtime.active = true
|
||||
try {
|
||||
return await runAcpTurn(runtime, prompt, options, spec, profile, true)
|
||||
return await runAcpTurn(runtime, prompt, options, spec, profile)
|
||||
} catch (error) {
|
||||
acpSessionRuntimes.delete(key)
|
||||
await closeAcpRuntime(runtime)
|
||||
@@ -1003,7 +997,7 @@ async function runDisposableAcpAgent(agent, prompt, workdir, options, spec, prof
|
||||
throw agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
|
||||
}
|
||||
acpDisposableRuntimes.add(runtime)
|
||||
return await runAcpTurn(runtime, prompt, options, spec, profile, false)
|
||||
return await runAcpTurn(runtime, prompt, options, spec, profile)
|
||||
} finally {
|
||||
if (runtime) {
|
||||
acpDisposableRuntimes.delete(runtime)
|
||||
|
||||
@@ -20,6 +20,7 @@ const {
|
||||
readHermesMessageWatermark,
|
||||
redactChildSecrets,
|
||||
runtimeCommandSummary,
|
||||
stripExactMeldworkPromptEcho,
|
||||
structuredCliError,
|
||||
} = require('./cli-runtime-events.cjs')
|
||||
const {
|
||||
@@ -127,6 +128,7 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
hermesAcpAvailable,
|
||||
invocationTransport: options.invocationTransport,
|
||||
sessionTransport: options.sessionTransport,
|
||||
maxTurns: options.maxTurns,
|
||||
})
|
||||
const profile = resolveConnectorEventProfile(agent.kind, {
|
||||
transport: spec.eventTransport,
|
||||
@@ -241,9 +243,12 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
let settled = false
|
||||
let stopRequested = false
|
||||
let timeoutRequested = false
|
||||
let inputError = null
|
||||
let timeout
|
||||
let forceKillTimeout
|
||||
let forceSettleTimeout
|
||||
let terminalKillTimer
|
||||
let terminalForceKillTimer
|
||||
const progress = []
|
||||
const progressIndexes = new Map()
|
||||
let anonymousProgressId = 0
|
||||
@@ -272,6 +277,7 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
&& typeof profile.createDecoder === 'function'
|
||||
? profile.createDecoder((event) => {
|
||||
structuredOutput.ingest?.(event)
|
||||
observeTerminalEvent(event)
|
||||
const progressEvent = profile.mapProgress(event)
|
||||
if (progressEvent) emitProgress(progressEvent)
|
||||
for (const runtimeEvent of profile.mapEvent(event, runtimeState)) {
|
||||
@@ -285,6 +291,8 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
clearTimeout(timeout)
|
||||
clearTimeout(forceKillTimeout)
|
||||
clearTimeout(forceSettleTimeout)
|
||||
clearTimeout(terminalKillTimer)
|
||||
clearTimeout(terminalForceKillTimer)
|
||||
options.signal?.removeEventListener('abort', abort)
|
||||
callback()
|
||||
}
|
||||
@@ -299,9 +307,31 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
child.kill(signal)
|
||||
} catch { /* the process has already exited */ }
|
||||
}
|
||||
let terminalObserved = false
|
||||
const observeTerminalEvent = (event) => {
|
||||
if (terminalObserved || !event || typeof event !== 'object') return
|
||||
const terminal = (
|
||||
(['workbuddy', 'claude', 'qwen'].includes(agent.kind) && event.type === 'result')
|
||||
|| (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')
|
||||
|| (agent.kind === 'gemini' && event.type === 'result')
|
||||
|| (agent.kind === 'kimi' && event.type === 'session.resume_hint')
|
||||
)
|
||||
if (!terminal) return
|
||||
terminalObserved = true
|
||||
terminalKillTimer = setTimeout(() => {
|
||||
if (settled) return
|
||||
signalProcessTree('SIGTERM')
|
||||
terminalForceKillTimer = setTimeout(() => {
|
||||
if (!settled) signalProcessTree('SIGKILL')
|
||||
}, 1500)
|
||||
}, 250)
|
||||
}
|
||||
const abort = () => {
|
||||
if (settled || stopRequested) return
|
||||
stopRequested = true
|
||||
const terminalError = inputError || new Error('LOCAL_AGENT_EXECUTION_STOPPED')
|
||||
if (platform === 'win32' && child.pid) {
|
||||
const killer = spawnFn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
|
||||
stdio: 'ignore', windowsHide: true,
|
||||
@@ -313,8 +343,8 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
child.stdout.destroy()
|
||||
child.stderr.destroy()
|
||||
rejectTerminal(
|
||||
new Error('LOCAL_AGENT_EXECUTION_STOPPED'),
|
||||
timeoutRequested ? 'timeout' : 'stopped',
|
||||
terminalError,
|
||||
inputError ? 'failed' : (timeoutRequested ? 'timeout' : 'stopped'),
|
||||
)
|
||||
}), KILL_SETTLE_MS)
|
||||
return
|
||||
@@ -328,8 +358,8 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
child.stdout.destroy()
|
||||
child.stderr.destroy()
|
||||
rejectTerminal(
|
||||
new Error('LOCAL_AGENT_EXECUTION_STOPPED'),
|
||||
timeoutRequested ? 'timeout' : 'stopped',
|
||||
terminalError,
|
||||
inputError ? 'failed' : (timeoutRequested ? 'timeout' : 'stopped'),
|
||||
)
|
||||
}), KILL_SETTLE_MS)
|
||||
}, TERMINATE_GRACE_MS)
|
||||
@@ -351,36 +381,33 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
stderr.push(chunk)
|
||||
noteActivity()
|
||||
})
|
||||
child.stdin.on('error', (error) => {
|
||||
if (settled || inputError) return
|
||||
inputError = agentExecutionError(
|
||||
'LOCAL_AGENT_SEND_FAILED',
|
||||
redactChildSecrets(error?.message || error, childEnv),
|
||||
)
|
||||
abort()
|
||||
})
|
||||
child.on('error', error => finish(() => rejectTerminal(
|
||||
stopRequested
|
||||
inputError || (stopRequested
|
||||
? agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')
|
||||
: agentExecutionError(
|
||||
'LOCAL_AGENT_SPAWN_FAILED',
|
||||
redactChildSecrets(error?.message || error, childEnv),
|
||||
),
|
||||
stopRequested ? (timeoutRequested ? 'timeout' : 'stopped') : 'failed',
|
||||
)),
|
||||
stopRequested ? (inputError ? 'failed' : (timeoutRequested ? 'timeout' : 'stopped')) : 'failed',
|
||||
)))
|
||||
child.on('close', (code, signal) => finish(() => {
|
||||
void (async () => {
|
||||
runtimeStreamParser?.end()
|
||||
if (stopRequested || options.signal?.aborted) {
|
||||
rejectTerminal(
|
||||
new Error('LOCAL_AGENT_EXECUTION_STOPPED'),
|
||||
timeoutRequested ? 'timeout' : 'stopped',
|
||||
inputError || new Error('LOCAL_AGENT_EXECUTION_STOPPED'),
|
||||
inputError ? 'failed' : (timeoutRequested ? 'timeout' : 'stopped'),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (code !== 0) {
|
||||
const rawStdout = stdout.text()
|
||||
const rawStderr = stderr.text().trim()
|
||||
const structuredDetail = structuredCliError(rawStdout)
|
||||
const detail = redactChildSecrets(
|
||||
[rawStderr, structuredDetail].filter(Boolean).join('\n'),
|
||||
childEnv,
|
||||
)
|
||||
rejectTerminal(failedAgentProcessError(detail, { sessionRef: failureSessionRef }))
|
||||
return
|
||||
}
|
||||
const rawStderr = stderr.text()
|
||||
const nextSessionRef = profile.resolveSessionRef({
|
||||
stderr: rawStderr,
|
||||
@@ -389,7 +416,23 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
})
|
||||
let result = structuredOutput.end({
|
||||
sessionRef: nextSessionRef,
|
||||
prompt,
|
||||
})
|
||||
const rawStdout = stdout.text()
|
||||
const structuredDetail = structuredCliError(rawStdout)
|
||||
const usableNonZeroResult = code !== 0
|
||||
&& result
|
||||
&& typeof result.text === 'string'
|
||||
&& result.text.trim()
|
||||
&& ['completed', 'partial'].includes(result.outcome)
|
||||
if (code !== 0 && !usableNonZeroResult) {
|
||||
const detail = redactChildSecrets(
|
||||
[rawStderr, structuredDetail].filter(Boolean).join('\n'),
|
||||
childEnv,
|
||||
)
|
||||
rejectTerminal(failedAgentProcessError(detail, { sessionRef: failureSessionRef }))
|
||||
return
|
||||
}
|
||||
if (result.error) {
|
||||
rejectTerminal(failedAgentProcessError(
|
||||
redactChildSecrets(result.error, childEnv),
|
||||
@@ -407,6 +450,10 @@ async function runAgent(agent, prompt, workdir, options = {}) {
|
||||
runContext: profileRunContext,
|
||||
options,
|
||||
})
|
||||
result = {
|
||||
...result,
|
||||
text: stripExactMeldworkPromptEcho(result.text, prompt),
|
||||
}
|
||||
if (progress.length) result.progress = progress
|
||||
const redactedText = redactChildSecrets(result.text, childEnv)
|
||||
let output
|
||||
|
||||
@@ -15,6 +15,7 @@ const AGENT_PROFILES = {
|
||||
hermes: { label: 'Hermes', commands: ['hermes'] },
|
||||
openclaw: { label: 'OpenClaw', commands: ['openclaw'] },
|
||||
workbuddy: { label: 'WorkBuddy', commands: ['codebuddy'] },
|
||||
pi: { label: 'Pi Agent', commands: ['pi', 'pi-agent', 'piagent'] },
|
||||
kimi: { label: 'Kimi', commands: ['kimi'] },
|
||||
mimo: { label: 'MiMo', commands: ['mimo'] },
|
||||
claude: { label: 'Claude', commands: ['claude'] },
|
||||
|
||||
@@ -14,6 +14,7 @@ const {
|
||||
parseMimoOutput,
|
||||
parseOpenCodeOutput,
|
||||
parseOpenCodeReviewOutput,
|
||||
parsePiOutput,
|
||||
parseWorkBuddyOutput,
|
||||
readHermesFinalResponse,
|
||||
readHermesMessageWatermark,
|
||||
@@ -26,11 +27,13 @@ const {
|
||||
createAcpRuntimeState,
|
||||
createClaudeQwenRuntimeState,
|
||||
createGeminiRuntimeState,
|
||||
createPiRuntimeState,
|
||||
finalOnlyRuntimeEvents,
|
||||
geminiStreamJsonRuntimeEvents,
|
||||
kimiStreamJsonRuntimeEvents,
|
||||
mimoJsonRuntimeEvents,
|
||||
openCodeJsonRuntimeEvents,
|
||||
piJsonRuntimeEvents,
|
||||
} = require('./cli-runtime-event-mappers.cjs')
|
||||
|
||||
function createStatelessRuntimeState() {
|
||||
@@ -75,6 +78,7 @@ const FINAL_OUTPUT_PARSERS = Object.freeze({
|
||||
codex: parseCodexOutput,
|
||||
openclaw: stdout => ({ text: normalizeOpenClawOutput(stdout), sessionRef: '' }),
|
||||
workbuddy: parseWorkBuddyOutput,
|
||||
pi: parsePiOutput,
|
||||
kimi: parseKimiOutput,
|
||||
mimo: parseMimoOutput,
|
||||
claude: parseClaudeQwenOutput,
|
||||
@@ -353,6 +357,21 @@ const OPENCODE_JSON_EVENTS_PROFILE = profile({
|
||||
createFinalOutputAccumulator: createFinalOutputAccumulator('opencode'),
|
||||
})
|
||||
|
||||
const PI_JSON_EVENTS_PROFILE = profile({
|
||||
profileId: 'pi-json-events-v2',
|
||||
protocol: 'pi-json',
|
||||
framing: 'jsonl',
|
||||
source: 'stdout',
|
||||
capabilities: {
|
||||
answerMode: 'delta', tools: FULL_TOOL_LIFECYCLE,
|
||||
plan: false, reasoning: true, session: true, terminal: true,
|
||||
},
|
||||
createDecoder: createJsonLineParser,
|
||||
createState: createPiRuntimeState,
|
||||
mapEvent: piJsonRuntimeEvents,
|
||||
createFinalOutputAccumulator: createFinalOutputAccumulator('pi'),
|
||||
})
|
||||
|
||||
const OPENCLAW_TERMINAL_DOCUMENT_PROFILE = profile({
|
||||
profileId: 'openclaw-terminal-document-v1',
|
||||
protocol: 'openclaw-terminal-document',
|
||||
@@ -436,6 +455,7 @@ const CONNECTOR_EVENT_PROFILES = Object.freeze({
|
||||
hermes: ACP_JSONRPC_PROFILE,
|
||||
openclaw: ACP_JSONRPC_PROFILE,
|
||||
workbuddy: WORKBUDDY_TERMINAL_RESULT_PROFILE,
|
||||
pi: PI_JSON_EVENTS_PROFILE,
|
||||
kimi: ACP_JSONRPC_PROFILE,
|
||||
mimo: MIMO_JSON_EVENTS_PROFILE,
|
||||
claude: ANTHROPIC_STREAM_JSON_PROFILE,
|
||||
@@ -450,6 +470,7 @@ const TRANSPORT_EVENT_PROFILES = Object.freeze({
|
||||
openclaw: Object.freeze({ legacy: OPENCLAW_TERMINAL_DOCUMENT_PROFILE }),
|
||||
kimi: Object.freeze({ 'stream-json': KIMI_STREAM_JSON_PROFILE }),
|
||||
mimo: Object.freeze({ acp: ACP_JSONRPC_PROFILE }),
|
||||
pi: Object.freeze({ json: PI_JSON_EVENTS_PROFILE }),
|
||||
opencode: Object.freeze({ acp: ACP_JSONRPC_PROFILE }),
|
||||
workbuddy: Object.freeze({ 'stream-json': ANTHROPIC_STREAM_JSON_PROFILE }),
|
||||
})
|
||||
|
||||
@@ -6,6 +6,17 @@ const IMAGE_ATTACHMENT_LIMITS = Object.freeze({
|
||||
hermes: 1,
|
||||
opencode: 4,
|
||||
})
|
||||
const DEFAULT_INTERNAL_TURN_LIMITS = Object.freeze({
|
||||
// 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) {
|
||||
const value = Number(requested)
|
||||
if (Number.isInteger(value) && value >= 1 && value <= 50) return value
|
||||
return DEFAULT_INTERNAL_TURN_LIMITS[kind]
|
||||
}
|
||||
|
||||
function codexSandbox(requested) {
|
||||
if (requested != null) {
|
||||
@@ -125,6 +136,7 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
|
||||
}
|
||||
}
|
||||
if (kind === 'workbuddy') {
|
||||
const maxTurns = internalTurnLimit(kind, options.maxTurns)
|
||||
return {
|
||||
command: executable,
|
||||
args: [
|
||||
@@ -132,13 +144,25 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
|
||||
'--output-format', 'stream-json',
|
||||
'--include-partial-messages',
|
||||
'--permission-mode', options.sandbox === 'workspace-write' ? 'acceptEdits' : 'plan',
|
||||
'--max-turns', '20',
|
||||
...(maxTurns ? ['--max-turns', String(maxTurns)] : []),
|
||||
...(sessionRef ? ['--resume', sessionRef] : []),
|
||||
],
|
||||
promptArg: true,
|
||||
eventTransport: 'stream-json',
|
||||
}
|
||||
}
|
||||
if (kind === 'pi') {
|
||||
return {
|
||||
command: executable,
|
||||
args: [
|
||||
'--mode', 'json', '--print',
|
||||
...(options.sandbox === 'workspace-write' ? ['--approve'] : ['--no-approve']),
|
||||
...(sessionRef ? ['--session', sessionRef] : []),
|
||||
],
|
||||
promptArg: true,
|
||||
eventTransport: 'json',
|
||||
}
|
||||
}
|
||||
if (kind === 'kimi') {
|
||||
if (options.sandbox !== 'workspace-write') {
|
||||
return {
|
||||
|
||||
@@ -10,7 +10,7 @@ const MAX_RUNTIME_JSON_PENDING_CHARS = 1024 * 1024
|
||||
const MAX_STRUCTURED_JSON_BYTES = 64 * 1024 * 1024
|
||||
const MAX_STRUCTURED_TEXT_CHARS = 1024 * 1024
|
||||
const STRUCTURED_JSONL_KINDS = new Set([
|
||||
'codex', 'workbuddy', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen',
|
||||
'codex', 'workbuddy', 'pi', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen',
|
||||
])
|
||||
const STRUCTURED_DOCUMENT_KINDS = new Set(['openclaw', 'opencodereview'])
|
||||
function parseCodexOutput(stdout) {
|
||||
@@ -90,6 +90,65 @@ function appendStructuredText(state, value, separator = '\n') {
|
||||
state.text += `${prefix}${text}`.slice(0, remaining)
|
||||
}
|
||||
|
||||
function stripPiInternalPromptEcho(value) {
|
||||
let text = String(value || '').trim()
|
||||
if (!text) return ''
|
||||
const preamble = /^You are participating in a local multi-agent discussion\.[\s\S]*?^Answer the user naturally(?: in Markdown)?\.?\s*/im
|
||||
text = text.replace(preamble, '').trim()
|
||||
// Pi may resume from a compact continuation prompt and echo only the
|
||||
// protocol instructions or the selected Harness records. Remove those
|
||||
// internal lines while preserving any user-facing text on the same line.
|
||||
text = text.replace(/^Do not output JSON, XML, receipt markers, protocol labels, hidden orchestration instructions, or a fixed response template\.\s*/im, '')
|
||||
text = text.replace(/^Do not claim another Agent performed work, and do not modify shared workspace state unless this turn explicitly grants that permission\.\s*/im, '')
|
||||
const lines = text.split(/\r?\n/)
|
||||
const cleaned = []
|
||||
let inRecords = false
|
||||
for (const line of lines) {
|
||||
const valueLine = line.trim()
|
||||
if (/^Selected records from completed phases:\s*$/i.test(valueLine)) {
|
||||
inRecords = true
|
||||
continue
|
||||
}
|
||||
if (inRecords) {
|
||||
if (/^MELDWORK_V4_COLLABORATION_PACKAGE_V1$/i.test(valueLine)
|
||||
|| /^\[index\]\s+\S+/i.test(valueLine)
|
||||
|| /^Artifact:\s+\S+/i.test(valueLine)
|
||||
|| /^Evidence:\s+\S+/i.test(valueLine)) continue
|
||||
const phasePrefix = /^(?:\[(?:challenge|proposal)\]\s+agent=\S+\s+Completed the (?:challenge|proposal) phase\.\s*)/i
|
||||
const remainder = line.replace(phasePrefix, '')
|
||||
if (remainder !== line) {
|
||||
if (remainder.trim()) cleaned.push(remainder)
|
||||
continue
|
||||
}
|
||||
inRecords = false
|
||||
}
|
||||
cleaned.push(line)
|
||||
}
|
||||
return cleaned.join('\n').replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
function stripPiPromptEcho(value, prompt) {
|
||||
const text = String(value || '').trim()
|
||||
const expected = String(prompt || '').trim()
|
||||
if (expected && text.startsWith(expected)) {
|
||||
return text.slice(expected.length).trim()
|
||||
}
|
||||
return stripPiInternalPromptEcho(text)
|
||||
}
|
||||
|
||||
function stripExactMeldworkPromptEcho(value, prompt) {
|
||||
const text = String(value || '')
|
||||
const expected = String(prompt || '').trim()
|
||||
if (!expected.includes('Current user task (authoritative):')
|
||||
|| !expected.startsWith('You are participating in the local ')) {
|
||||
return text.trim()
|
||||
}
|
||||
const candidate = text.trimStart()
|
||||
return candidate.startsWith(expected)
|
||||
? candidate.slice(expected.length).trimStart()
|
||||
: text.trim()
|
||||
}
|
||||
|
||||
function markStructuredFailure(state, code = 'LOCAL_AGENT_PROCESS_FAILED', diagnostic = '') {
|
||||
state.outcome = 'failed'
|
||||
state.failure = agentRuntimeFailure(code)
|
||||
@@ -116,6 +175,16 @@ function stepFinishOutcome(reason) {
|
||||
function ingestStructuredEvent(state, event) {
|
||||
if (!event || typeof event !== 'object' || Array.isArray(event)) return
|
||||
state.seen = true
|
||||
if (['workbuddy', 'claude', 'qwen'].includes(state.kind)
|
||||
&& event.type === 'result' && typeof event.result === 'string') {
|
||||
if (typeof event.session_id === 'string') state.sessionRef = event.session_id
|
||||
state.failure = null
|
||||
state.diagnostic = ''
|
||||
state.text = ''
|
||||
appendStructuredText(state, event.result, '')
|
||||
state.outcome = event.is_error === true ? 'partial' : 'completed'
|
||||
return
|
||||
}
|
||||
if (structuredEventFailed(event)) {
|
||||
markStructuredFailure(state)
|
||||
return
|
||||
@@ -133,15 +202,7 @@ function ingestStructuredEvent(state, event) {
|
||||
return
|
||||
}
|
||||
|
||||
if (['workbuddy', 'claude', 'qwen'].includes(state.kind)) {
|
||||
if (typeof event.session_id === 'string') state.sessionRef = event.session_id
|
||||
if (event.type === 'result' && typeof event.result === 'string') {
|
||||
state.text = ''
|
||||
appendStructuredText(state, event.result, '')
|
||||
state.outcome = 'completed'
|
||||
}
|
||||
return
|
||||
}
|
||||
if (['workbuddy', 'claude', 'qwen'].includes(state.kind)) return
|
||||
|
||||
if (state.kind === 'kimi') {
|
||||
if (event.role === 'assistant' && typeof event.content === 'string') {
|
||||
@@ -154,6 +215,30 @@ function ingestStructuredEvent(state, event) {
|
||||
return
|
||||
}
|
||||
|
||||
if (state.kind === 'pi') {
|
||||
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' && 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, '')
|
||||
}
|
||||
}
|
||||
const stopReason = event.message.stopReason
|
||||
if (stopReason) state.outcome = stepFinishOutcome(stopReason)
|
||||
}
|
||||
if (event.type === 'turn_end') {
|
||||
state.outcome = stepFinishOutcome(event.message?.stopReason || event.stopReason || 'stop')
|
||||
}
|
||||
if (event.type === 'agent_end') state.outcome = 'completed'
|
||||
if (state.outcome === 'failed') markStructuredFailure(state)
|
||||
return
|
||||
}
|
||||
|
||||
if (['mimo', 'opencode'].includes(state.kind)) {
|
||||
if (typeof event.sessionID === 'string') state.sessionRef = event.sessionID
|
||||
if (event.type === 'text' && event.part?.type === 'text'
|
||||
@@ -273,7 +358,7 @@ function openCodeReviewTerminal(status, manifest, manifestPresent = manifest !=
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeStructuredState(state) {
|
||||
function finalizeStructuredState(state, context = {}) {
|
||||
if (state.parseError) {
|
||||
return {
|
||||
text: '',
|
||||
@@ -315,7 +400,9 @@ function finalizeStructuredState(state) {
|
||||
if (!state.seen && !state.text && !state.outcome) return null
|
||||
const outcome = state.outcome || (state.text ? 'partial' : 'failed')
|
||||
const result = {
|
||||
text: state.text.trim(),
|
||||
text: state.kind === 'pi'
|
||||
? stripPiPromptEcho(state.text, context.prompt)
|
||||
: state.text.trim(),
|
||||
sessionRef: state.sessionRef,
|
||||
outcome,
|
||||
}
|
||||
@@ -348,7 +435,7 @@ function createStructuredOutputAccumulator(kind, sessionRef = '') {
|
||||
return {
|
||||
format: 'jsonl',
|
||||
ingest: event => ingestStructuredEvent(state, event),
|
||||
end: () => finalizeStructuredState(state),
|
||||
end: context => finalizeStructuredState(state, context),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,6 +698,29 @@ function parseOpenCodeOutput(stdout) {
|
||||
}
|
||||
}
|
||||
|
||||
function parsePiOutput(stdout) {
|
||||
const events = parseJsonOutputEvents(stdout)
|
||||
let sessionRef = ''
|
||||
let text = ''
|
||||
for (const event of events) {
|
||||
if (typeof event.id === 'string' && event.type === 'session') sessionRef = event.id
|
||||
if (event.type === 'message_update'
|
||||
&& event.assistantMessageEvent?.type === 'text_delta'
|
||||
&& typeof event.assistantMessageEvent.delta === 'string') {
|
||||
text += event.assistantMessageEvent.delta
|
||||
}
|
||||
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)
|
||||
.join('')
|
||||
}
|
||||
}
|
||||
const parsedText = text.trim()
|
||||
const answer = stripPiInternalPromptEcho(parsedText)
|
||||
return { text: parsedText ? answer : String(stdout || '').trim(), sessionRef }
|
||||
}
|
||||
|
||||
function openCodeReviewText(output) {
|
||||
const sections = []
|
||||
if (typeof output.message === 'string' && output.message.trim()) {
|
||||
@@ -686,6 +796,14 @@ function classifyCliOutcome(kind, stdout) {
|
||||
}
|
||||
|
||||
const events = parseJsonOutputEvents(stdout)
|
||||
if (['workbuddy', 'claude', 'qwen'].includes(kind)) {
|
||||
const result = events.findLast(event => event?.type === 'result' && typeof event.result === 'string')
|
||||
if (result) {
|
||||
return {
|
||||
outcome: result.is_error === true ? 'partial' : 'completed',
|
||||
}
|
||||
}
|
||||
}
|
||||
const failed = events.findLast(event => (
|
||||
event?.type === 'error'
|
||||
|| event?.status === 'failed'
|
||||
@@ -706,11 +824,6 @@ function classifyCliOutcome(kind, stdout) {
|
||||
: 'partial',
|
||||
}
|
||||
}
|
||||
if (['workbuddy', 'claude', 'qwen'].includes(kind)) {
|
||||
return {
|
||||
outcome: events.some(event => event?.type === 'result') ? 'completed' : 'partial',
|
||||
}
|
||||
}
|
||||
if (kind === 'kimi') {
|
||||
return {
|
||||
outcome: events.some(event => event?.type === 'session.resume_hint')
|
||||
@@ -718,6 +831,11 @@ function classifyCliOutcome(kind, stdout) {
|
||||
: 'partial',
|
||||
}
|
||||
}
|
||||
if (kind === 'pi') {
|
||||
const terminal = events.findLast(event => event?.type === 'turn_end' || event?.type === 'agent_end')
|
||||
const reason = terminal?.message?.stopReason || terminal?.stopReason || 'stop'
|
||||
return { outcome: stepFinishOutcome(reason) }
|
||||
}
|
||||
if (['mimo', 'opencode'].includes(kind)) {
|
||||
const finish = events.findLast(event => (
|
||||
event?.type === 'step_finish' || event?.part?.type === 'step-finish'
|
||||
@@ -833,11 +951,13 @@ module.exports = {
|
||||
parseKimiOutput,
|
||||
parseMimoOutput,
|
||||
parseOpenCodeOutput,
|
||||
parsePiOutput,
|
||||
parseOpenCodeReviewOutput,
|
||||
parseWorkBuddyOutput,
|
||||
readHermesFinalResponse,
|
||||
readHermesMessageWatermark,
|
||||
stripAnsi,
|
||||
stripExactMeldworkPromptEcho,
|
||||
structuredCliError,
|
||||
classifyCliOutcome,
|
||||
}
|
||||
@@ -345,6 +345,70 @@ function openCodeJsonRuntimeEvents(event) {
|
||||
return []
|
||||
}
|
||||
|
||||
function createPiRuntimeState() {
|
||||
return {
|
||||
reasoning: new Map(),
|
||||
tools: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
function piJsonRuntimeEvents(event, state) {
|
||||
if (!event || typeof event !== 'object') return []
|
||||
const update = event.assistantMessageEvent
|
||||
if (event.type === 'message_update' && update && typeof update === 'object') {
|
||||
const index = Number.isInteger(update.contentIndex) ? update.contentIndex : 0
|
||||
const id = `content:${index}`
|
||||
if (update.type === 'thinking_start') {
|
||||
state.reasoning.set(id, '')
|
||||
return [{ id, type: 'reasoning_summary', title: 'reasoning', status: 'running' }]
|
||||
}
|
||||
if (update.type === 'thinking_delta' && typeof update.delta === 'string') {
|
||||
const summary = `${state.reasoning.get(id) || ''}${update.delta}`.slice(-2000)
|
||||
state.reasoning.set(id, summary)
|
||||
return [{ id, type: 'reasoning_summary', title: 'reasoning', status: 'running', summary }]
|
||||
}
|
||||
if (update.type === 'thinking_end') {
|
||||
const summary = String(update.content || state.reasoning.get(id) || '').trim()
|
||||
state.reasoning.set(id, summary)
|
||||
return [{ id, type: 'reasoning_summary', title: 'reasoning', status: 'completed', ...(summary ? { summary } : {}) }]
|
||||
}
|
||||
if (update.type === 'text_delta' && typeof update.delta === 'string') {
|
||||
return [{ type: 'answer_delta', status: 'running', delta: update.delta }]
|
||||
}
|
||||
if (['toolcall_start', 'tool_call_start'].includes(update.type)) {
|
||||
const toolId = String(update.toolCallId || update.id || `tool:${index}`)
|
||||
const title = String(update.toolName || update.name || 'tool')
|
||||
state.tools.set(toolId, title)
|
||||
return [{ id: toolId, type: 'tool_start', title, status: 'running' }]
|
||||
}
|
||||
if (['toolcall_end', 'tool_call_end'].includes(update.type)) {
|
||||
const toolId = String(update.toolCallId || update.id || `tool:${index}`)
|
||||
return [{
|
||||
id: toolId,
|
||||
type: 'tool_result_summary',
|
||||
title: state.tools.get(toolId) || String(update.toolName || update.name || 'tool'),
|
||||
status: update.isError === true || update.error ? 'failed' : 'completed',
|
||||
}]
|
||||
}
|
||||
}
|
||||
if (event.type === 'tool_execution_start' || event.type === 'tool_start') {
|
||||
const id = String(event.toolCallId || event.id || 'tool')
|
||||
const title = String(event.toolName || event.name || 'tool')
|
||||
state.tools.set(id, title)
|
||||
return [{ id, type: 'tool_start', title, status: 'running' }]
|
||||
}
|
||||
if (event.type === 'tool_execution_end' || event.type === 'tool_end') {
|
||||
const id = String(event.toolCallId || event.id || 'tool')
|
||||
return [{
|
||||
id,
|
||||
type: 'tool_result_summary',
|
||||
title: state.tools.get(id) || String(event.toolName || event.name || 'tool'),
|
||||
status: event.isError === true || event.error ? 'failed' : 'completed',
|
||||
}]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function finalOnlyRuntimeEvents() {
|
||||
return []
|
||||
}
|
||||
@@ -432,4 +496,6 @@ module.exports = {
|
||||
kimiStreamJsonRuntimeEvents,
|
||||
mimoJsonRuntimeEvents,
|
||||
openCodeJsonRuntimeEvents,
|
||||
piJsonRuntimeEvents,
|
||||
createPiRuntimeState,
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const {
|
||||
readHermesFinalResponse,
|
||||
readHermesMessageWatermark,
|
||||
stripAnsi,
|
||||
stripExactMeldworkPromptEcho,
|
||||
structuredCliError,
|
||||
} = require('./cli-output-parsers.cjs')
|
||||
const {
|
||||
@@ -59,5 +60,6 @@ module.exports = {
|
||||
redactChildSecrets,
|
||||
runtimeCommandSummary,
|
||||
stripAnsi,
|
||||
stripExactMeldworkPromptEcho,
|
||||
structuredCliError,
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
const fs = require('node:fs')
|
||||
const http = require('node:http')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { spawn, spawnSync } = require('node:child_process')
|
||||
|
||||
const requestedPort = Number(process.env.MELDWORK_AGENT_BRIDGE_PORT || 8765)
|
||||
const PORT = Number.isInteger(requestedPort) && requestedPort >= 1024 && requestedPort <= 65535
|
||||
? requestedPort
|
||||
: 8765
|
||||
const MAX_REQUEST_BYTES = 4 * 1024 * 1024
|
||||
const MAX_RESPONSE_BYTES = 256 * 1024
|
||||
const MAX_SKILLS = 64
|
||||
const PUBLIC_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/
|
||||
|
||||
const AGENT_DEFINITIONS = Object.freeze([
|
||||
{
|
||||
id: 'codex', label: 'Codex',
|
||||
description: 'Codex CLI exposed through an SSH-local Bridge.',
|
||||
auth(command) { return command('codex', ['login', 'status']).status === 0 },
|
||||
},
|
||||
{
|
||||
id: 'hermes', label: 'Hermes',
|
||||
description: 'Hermes Agent exposed through an SSH-local Bridge.',
|
||||
auth() { return true },
|
||||
},
|
||||
{
|
||||
id: 'claude', label: 'Claude Code',
|
||||
description: 'Claude Code exposed through an SSH-local Bridge.',
|
||||
auth(command) {
|
||||
const result = command('claude', ['auth', 'status'])
|
||||
if (result.status !== 0) return false
|
||||
try { return JSON.parse(String(result.stdout || '')).loggedIn === true } catch { return false }
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
function json(res, status, body) {
|
||||
const payload = JSON.stringify(body)
|
||||
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) })
|
||||
res.end(payload)
|
||||
}
|
||||
|
||||
function versionFrom(value) {
|
||||
return String(value || '').match(/(?:^|[^0-9])v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/)?.[1] || ''
|
||||
}
|
||||
|
||||
function commandResult(spawnSyncFn, command, args) {
|
||||
const result = spawnSyncFn(command, args, { encoding: 'utf8', timeout: 5000 }) || {}
|
||||
return {
|
||||
status: Number.isInteger(result.status) ? result.status : 1,
|
||||
stdout: String(result.stdout || ''),
|
||||
}
|
||||
}
|
||||
|
||||
function listSkills(kind, options = {}) {
|
||||
const readdirSync = options.readdirSync || fs.readdirSync
|
||||
const home = options.home || os.homedir()
|
||||
const roots = {
|
||||
codex: [path.join(home, '.codex', 'skills'), path.join(home, '.agents', 'skills')],
|
||||
hermes: [path.join(home, '.hermes', 'skills'), path.join(home, '.agents', 'skills')],
|
||||
claude: [path.join(home, '.claude', 'skills'), path.join(home, '.agents', 'skills')],
|
||||
}[kind] || []
|
||||
const names = new Set()
|
||||
for (const root of roots) {
|
||||
let entries = []
|
||||
try { entries = readdirSync(root, { withFileTypes: true }) } catch { continue }
|
||||
for (const entry of entries) {
|
||||
if (names.size >= MAX_SKILLS) break
|
||||
if ((entry.isDirectory?.() || entry.isSymbolicLink?.()) && PUBLIC_NAME.test(entry.name)) names.add(entry.name)
|
||||
}
|
||||
}
|
||||
return [...names].sort().map(name => ({ namespace: kind, slug: name, name }))
|
||||
}
|
||||
|
||||
function discoverAgents(options = {}) {
|
||||
const spawnSyncFn = options.spawnSync || spawnSync
|
||||
const listSkillsFn = options.listSkills || (kind => listSkills(kind))
|
||||
const command = (name, args) => commandResult(spawnSyncFn, name, args)
|
||||
const agents = []
|
||||
for (const definition of AGENT_DEFINITIONS) {
|
||||
const versionResult = command(definition.id, ['--version'])
|
||||
const version = versionResult.status === 0 ? versionFrom(versionResult.stdout) : ''
|
||||
if (!version) continue
|
||||
const available = definition.auth(command)
|
||||
agents.push({
|
||||
id: definition.id,
|
||||
sourceKind: definition.id,
|
||||
label: definition.label,
|
||||
version,
|
||||
description: definition.description,
|
||||
domains: ['general'],
|
||||
inputTypes: ['text'],
|
||||
permissionModes: ['read-only'],
|
||||
available,
|
||||
credentialState: available ? 'ready' : 'missing',
|
||||
skills: listSkillsFn(definition.id),
|
||||
session: {
|
||||
supported: true,
|
||||
resume: true,
|
||||
cancel: true,
|
||||
checkpoint: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
return agents
|
||||
}
|
||||
|
||||
function invocationFor(agentId, prompt, sessionRef = '') {
|
||||
if (agentId === 'codex') {
|
||||
return {
|
||||
command: 'codex',
|
||||
args: sessionRef
|
||||
? ['exec', 'resume', '--json', '--skip-git-repo-check', '--sandbox', 'read-only', sessionRef, '-']
|
||||
: ['exec', '--json', '--skip-git-repo-check', '--sandbox', 'read-only', '-'],
|
||||
input: prompt,
|
||||
}
|
||||
}
|
||||
if (agentId === 'hermes') {
|
||||
return {
|
||||
command: 'hermes',
|
||||
args: ['chat', '--quiet', ...(sessionRef ? ['--resume', sessionRef] : []), '--query', prompt],
|
||||
input: '',
|
||||
}
|
||||
}
|
||||
if (agentId === 'claude') {
|
||||
return {
|
||||
command: 'claude',
|
||||
args: [
|
||||
'--print', '--output-format', 'json', '--permission-mode', 'plan',
|
||||
...(sessionRef ? ['--resume', sessionRef] : []), prompt,
|
||||
],
|
||||
input: '',
|
||||
}
|
||||
}
|
||||
throw new Error('REMOTE_AGENT_UNSUPPORTED')
|
||||
}
|
||||
|
||||
function parseAgentOutput(agentId, stdout) {
|
||||
if (agentId === 'codex') {
|
||||
const messages = []
|
||||
let sessionRef = ''
|
||||
for (const line of String(stdout || '').split(/\r?\n/)) {
|
||||
try {
|
||||
const event = JSON.parse(line)
|
||||
if (event.type === 'thread.started' && typeof event.thread_id === 'string') sessionRef = event.thread_id
|
||||
if (event.type === 'item.completed' && event.item?.type === 'agent_message'
|
||||
&& typeof event.item.text === 'string') messages.push(event.item.text)
|
||||
} catch { /* ignore non-JSON diagnostics */ }
|
||||
}
|
||||
const text = messages.join('\n').trim()
|
||||
return text ? { text, sessionRef, outcome: 'completed' } : null
|
||||
}
|
||||
if (agentId === 'hermes') {
|
||||
const lines = String(stdout || '').split(/\r?\n/)
|
||||
let sessionRef = ''
|
||||
const text = lines.filter((line) => {
|
||||
const match = line.match(/^session_id:\s*(\S+)\s*$/)
|
||||
if (match) {
|
||||
sessionRef = match[1]
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}).join('\n').trim()
|
||||
return text ? { text, sessionRef, outcome: 'completed' } : null
|
||||
}
|
||||
if (agentId === 'claude') {
|
||||
let result
|
||||
try { result = JSON.parse(String(stdout || '')) } catch { return null }
|
||||
if (result?.is_error === true || typeof result?.result !== 'string' || !result.result.trim()) return null
|
||||
return { text: result.result.trim(), sessionRef: String(result.session_id || ''), outcome: 'completed' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = ''
|
||||
req.on('data', chunk => {
|
||||
body += chunk.toString('utf8')
|
||||
if (Buffer.byteLength(body) > MAX_REQUEST_BYTES) reject(new Error('request too large'))
|
||||
})
|
||||
req.once('end', () => resolve(body))
|
||||
req.once('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
function parentSessionDetached(parentPid = process.ppid, readFileSync = fs.readFileSync) {
|
||||
if (!Number.isInteger(parentPid) || parentPid <= 1 || process.ppid === 1) return true
|
||||
try {
|
||||
const stat = String(readFileSync(`/proc/${parentPid}/stat`, 'utf8'))
|
||||
const match = stat.match(/^\d+\s+\(.+\)\s+\S+\s+(\d+)\s/)
|
||||
return match ? Number(match[1]) === 1 : false
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function runAgent(agentId, prompt, sessionRef = '') {
|
||||
return new Promise(resolve => {
|
||||
let invocation
|
||||
try { invocation = invocationFor(agentId, prompt, sessionRef) } catch {
|
||||
resolve({ outcome: 'failed', failure: { code: 'REMOTE_AGENT_UNSUPPORTED' } })
|
||||
return
|
||||
}
|
||||
const child = spawn(invocation.command, invocation.args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'], cwd: process.env.HOME || undefined,
|
||||
})
|
||||
let stdout = ''
|
||||
child.stdout.on('data', chunk => {
|
||||
stdout += chunk.toString('utf8')
|
||||
if (Buffer.byteLength(stdout) > MAX_RESPONSE_BYTES) child.kill('SIGTERM')
|
||||
})
|
||||
child.stderr.resume()
|
||||
child.once('error', () => resolve({ outcome: 'failed', failure: { code: 'REMOTE_AGENT_UNAVAILABLE' } }))
|
||||
child.once('close', code => {
|
||||
if (code !== 0) return resolve({ outcome: 'failed', failure: { code: 'REMOTE_AGENT_FAILED' } })
|
||||
resolve(parseAgentOutput(agentId, stdout)
|
||||
|| { outcome: 'failed', failure: { code: 'REMOTE_AGENT_RESPONSE_INVALID' } })
|
||||
})
|
||||
child.stdin.end(invocation.input)
|
||||
})
|
||||
}
|
||||
|
||||
function createServer() {
|
||||
return http.createServer(async (req, res) => {
|
||||
if (req.method === 'GET' && req.url === '/.well-known/meldwork-agent-bridge') {
|
||||
return json(res, 200, {
|
||||
protocol: 'meldwork-agent-bridge', version: 1,
|
||||
server: { id: 'ssh-session', label: 'SSH Agent Server' },
|
||||
agents: discoverAgents(),
|
||||
})
|
||||
}
|
||||
const match = req.method === 'POST' && req.url?.match(/^\/v1\/agents\/([A-Za-z0-9._-]+)\/runs$/)
|
||||
if (!match) return json(res, 404, { error: 'NOT_FOUND' })
|
||||
try {
|
||||
const body = JSON.parse(await readBody(req))
|
||||
const agent = discoverAgents().find(item => item.id === match[1] && item.available)
|
||||
if (!agent || typeof body.prompt !== 'string' || body.permissionMode !== 'read-only') {
|
||||
return json(res, 400, { error: 'REQUEST_INVALID' })
|
||||
}
|
||||
return json(res, 200, await runAgent(agent.id, body.prompt, body.sessionRef || ''))
|
||||
} catch {
|
||||
return json(res, 400, { error: 'REQUEST_INVALID' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function startServer() {
|
||||
const server = createServer()
|
||||
const parentPid = process.ppid
|
||||
const parentTimer = setInterval(() => {
|
||||
if (!parentSessionDetached(parentPid)) return
|
||||
server.close(() => process.exit(0))
|
||||
}, 1000)
|
||||
server.once('close', () => clearInterval(parentTimer))
|
||||
server.listen(PORT, '127.0.0.1', () => {
|
||||
process.stdout.write(`MELDWORK_AGENT_BRIDGE_READY ${PORT}\n`)
|
||||
})
|
||||
process.once('SIGTERM', () => server.close(() => process.exit(0)))
|
||||
process.once('SIGINT', () => server.close(() => process.exit(0)))
|
||||
return server
|
||||
}
|
||||
|
||||
if (require.main === module || !require.main) startServer()
|
||||
|
||||
module.exports = {
|
||||
createServer,
|
||||
discoverAgents,
|
||||
invocationFor,
|
||||
listSkills,
|
||||
parentSessionDetached,
|
||||
parseAgentOutput,
|
||||
runAgent,
|
||||
startServer,
|
||||
}
|
||||
@@ -0,0 +1,721 @@
|
||||
const crypto = require('node:crypto')
|
||||
const { spawn } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const net = require('node:net')
|
||||
|
||||
const { canonicalJson } = require('../../collaboration/outcome-records.cjs')
|
||||
const { atomicWritePrivateFile } = require('../../security/private-file.cjs')
|
||||
const { createAgentConnectorManifest } = require('../connectors/agent-connector-manifest.cjs')
|
||||
|
||||
const BRIDGE_PROTOCOL = 'meldwork-agent-bridge'
|
||||
const BRIDGE_VERSION = 1
|
||||
const BRIDGE_RECIPE_ID = 'external.cloud-agent-bridge'
|
||||
const SSH_RECIPE_ID = 'external.cloud-agent-ssh'
|
||||
const BRIDGE_UPSTREAM_ID = 'meldwork-cloud'
|
||||
const BRIDGE_UPSTREAM_VERSION = '1.0.0'
|
||||
const BRIDGE_ID = /^cloud-bridge-[a-f0-9]{24}$/
|
||||
const PUBLIC_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/
|
||||
const MAX_RESPONSE_BYTES = 256 * 1024
|
||||
const MAX_AGENTS = 32
|
||||
const MAX_SKILLS = 64
|
||||
const STORE_VERSION = 1
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 3 * 60 * 1000
|
||||
const DEFAULT_RUN_TIMEOUT_MS = 10 * 60 * 1000
|
||||
const MAX_REQUEST_TIMEOUT_MS = 15 * 60 * 1000
|
||||
const SSH_DISCOVERY_COMMAND = 'if command -v codex >/dev/null 2>&1; then codex --version; fi'
|
||||
const SSH_CODEX_COMMAND = 'exec codex exec --json --skip-git-repo-check --sandbox read-only -'
|
||||
const SSH_TUNNEL_READY = 'MELDWORK_AGENT_BRIDGE_READY'
|
||||
const SSH_BRIDGE_SCRIPT = fs.readFileSync(require.resolve('./cloud-agent-bridge-server.cjs'), 'utf8')
|
||||
|
||||
function loginShellCommand(command) {
|
||||
const escaped = String(command).replace(/'/g, `'"'"'`)
|
||||
return `bash -lc '${escaped}'`
|
||||
}
|
||||
|
||||
function bridgeError(code) {
|
||||
const error = new Error(code)
|
||||
error.code = code
|
||||
return error
|
||||
}
|
||||
|
||||
function fail(code) {
|
||||
throw bridgeError(code)
|
||||
}
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(canonicalJson(value))
|
||||
}
|
||||
|
||||
function cleanText(value, max = 240) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text || text.length > max || /[\u0000-\u001f\u007f]/.test(text)) return ''
|
||||
return text
|
||||
}
|
||||
|
||||
function normalizeSkill(input) {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) return null
|
||||
const namespace = cleanText(input.namespace, 80)
|
||||
const slug = cleanText(input.slug, 120)
|
||||
const name = cleanText(input.name || input.slug, 120)
|
||||
if (!PUBLIC_ID.test(namespace) || !PUBLIC_ID.test(slug) || !name) return null
|
||||
return { namespace, slug, name }
|
||||
}
|
||||
|
||||
function normalizeAddress(value) {
|
||||
const raw = String(value || '').trim().replace(/\/$/, '')
|
||||
const address = /^[a-z][a-z\d+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`
|
||||
let parsed
|
||||
try { parsed = new URL(address) } catch { fail('CLOUD_AGENT_BRIDGE_ADDRESS_INVALID') }
|
||||
if (!['https:'].includes(parsed.protocol)
|
||||
|| parsed.username || parsed.password || parsed.pathname !== '/'
|
||||
|| parsed.search || parsed.hash || !parsed.hostname
|
||||
|| net.isIP(parsed.hostname) === 0) {
|
||||
fail('CLOUD_AGENT_BRIDGE_ADDRESS_INVALID')
|
||||
}
|
||||
return parsed.origin
|
||||
}
|
||||
|
||||
function normalizeSshAddress(value) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw || /^[a-z][a-z\d+.-]*:\/\//i.test(raw) || /[\s@/?#]/.test(raw)) {
|
||||
fail('CLOUD_AGENT_SSH_ADDRESS_INVALID')
|
||||
}
|
||||
const match = raw.match(/^([^:]+)(?::(\d{1,5}))?$/)
|
||||
if (!match || net.isIP(match[1]) === 0) fail('CLOUD_AGENT_SSH_ADDRESS_INVALID')
|
||||
const port = match[2] ? Number(match[2]) : 22
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) fail('CLOUD_AGENT_SSH_ADDRESS_INVALID')
|
||||
return { host: match[1], port, address: port === 22 ? match[1] : `${match[1]}:${port}` }
|
||||
}
|
||||
|
||||
function normalizeTunnelEndpoint(value) {
|
||||
let parsed
|
||||
try { parsed = new URL(String(value || '')) } catch { fail('CLOUD_AGENT_SSH_TUNNEL_INVALID') }
|
||||
if (parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1'
|
||||
|| parsed.pathname !== '/' || parsed.search || parsed.hash
|
||||
|| parsed.username || parsed.password || !parsed.port) {
|
||||
fail('CLOUD_AGENT_SSH_TUNNEL_INVALID')
|
||||
}
|
||||
return parsed.origin
|
||||
}
|
||||
|
||||
function normalizeAgent(input) {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
fail('CLOUD_AGENT_BRIDGE_MANIFEST_INVALID')
|
||||
}
|
||||
const id = cleanText(input.id, 80)
|
||||
const sourceKind = cleanText(input.sourceKind || input.id, 80)
|
||||
const label = cleanText(input.label || input.name, 120)
|
||||
const version = cleanText(input.version, 80)
|
||||
if (!PUBLIC_ID.test(id) || !PUBLIC_ID.test(sourceKind) || !label || !version) {
|
||||
fail('CLOUD_AGENT_BRIDGE_MANIFEST_INVALID')
|
||||
}
|
||||
const domains = Array.isArray(input.domains) && input.domains.length
|
||||
? input.domains.map(item => cleanText(item, 80)).filter(item => PUBLIC_ID.test(item))
|
||||
: ['general']
|
||||
const inputTypes = Array.isArray(input.inputTypes) && input.inputTypes.length
|
||||
? input.inputTypes.filter(item => ['text', 'image', 'audio', 'video', 'file', 'structured-data'].includes(item))
|
||||
: ['text']
|
||||
const permissionModes = Array.isArray(input.permissionModes) && input.permissionModes.length
|
||||
? input.permissionModes.filter(item => ['read-only', 'workspace-write'].includes(item))
|
||||
: ['read-only']
|
||||
if (!domains.length || !inputTypes.includes('text') || !permissionModes.length) {
|
||||
fail('CLOUD_AGENT_BRIDGE_MANIFEST_INVALID')
|
||||
}
|
||||
const credentialState = ['ready', 'missing', 'unknown'].includes(input.credentialState)
|
||||
? input.credentialState
|
||||
: (input.available === false ? 'unknown' : 'ready')
|
||||
const skills = (Array.isArray(input.skills) ? input.skills : [])
|
||||
.map(normalizeSkill)
|
||||
.filter(Boolean)
|
||||
.slice(0, MAX_SKILLS)
|
||||
return {
|
||||
id, sourceKind, label, version,
|
||||
description: cleanText(input.description, 400),
|
||||
available: input.available !== false,
|
||||
credentialState,
|
||||
skills,
|
||||
domains: [...new Set(domains)].slice(0, 16),
|
||||
inputTypes: [...new Set(inputTypes)].slice(0, 16),
|
||||
permissionModes: [...new Set(permissionModes)],
|
||||
session: {
|
||||
supported: input.session?.supported !== false,
|
||||
resume: input.session?.resume !== false,
|
||||
cancel: input.session?.cancel !== false,
|
||||
checkpoint: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProbe(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)
|
||||
|| value.protocol !== BRIDGE_PROTOCOL || value.version !== BRIDGE_VERSION
|
||||
|| !Array.isArray(value.agents) || !value.agents.length || value.agents.length > MAX_AGENTS) {
|
||||
fail('CLOUD_AGENT_BRIDGE_MANIFEST_INVALID')
|
||||
}
|
||||
const serverLabel = cleanText(value.server?.label || value.server?.name, 120)
|
||||
return {
|
||||
serverId: cleanText(value.server?.id, 120),
|
||||
serverLabel: serverLabel || 'Cloud Agent Server',
|
||||
agents: value.agents.map(normalizeAgent),
|
||||
}
|
||||
}
|
||||
|
||||
function bridgeIdFor(address) {
|
||||
return `cloud-bridge-${crypto.createHash('sha256').update(address).digest('hex').slice(0, 24)}`
|
||||
}
|
||||
|
||||
function instanceIdFor(bridgeId, agentId) {
|
||||
return `cloud-${crypto.createHash('sha256').update(`${bridgeId}:${agentId}`).digest('hex').slice(0, 24)}`
|
||||
}
|
||||
|
||||
function manifestFor(record, agent) {
|
||||
const ssh = record.transport === 'ssh'
|
||||
const tunnel = record.transport === 'ssh-tunnel'
|
||||
return createAgentConnectorManifest({
|
||||
connectorId: instanceIdFor(record.bridgeId, agent.id),
|
||||
connectorVersion: '1.0.0',
|
||||
kind: 'agent',
|
||||
label: `${agent.label} @ ${record.label}`,
|
||||
description: agent.description || `Agent CLI exposed by ${record.label}.`,
|
||||
transport: ssh ? { type: 'cli', protocol: 'jsonl' } : { type: 'http', protocol: 'json' },
|
||||
upstream: ssh
|
||||
? { id: agent.id, minVersion: BRIDGE_UPSTREAM_VERSION, maxVersion: BRIDGE_UPSTREAM_VERSION }
|
||||
: { id: BRIDGE_UPSTREAM_ID, minVersion: BRIDGE_UPSTREAM_VERSION, maxVersion: BRIDGE_UPSTREAM_VERSION },
|
||||
invocation: { recipeId: ssh ? SSH_RECIPE_ID : BRIDGE_RECIPE_ID, idempotencyMode: 'durable' },
|
||||
domains: agent.domains,
|
||||
session: agent.session,
|
||||
inputTypes: agent.inputTypes,
|
||||
permissionModes: agent.permissionModes,
|
||||
eventProtocolVersion: 1,
|
||||
eventTypes: ['Completed', 'Failed', 'Cancelled'],
|
||||
usage: {
|
||||
inputTokens: false, outputTokens: false, costMicros: false,
|
||||
toolCalls: false, outboundBytes: !ssh && !tunnel, elapsedMs: true,
|
||||
},
|
||||
outboundDestinations: ssh || tunnel ? [] : [record.address],
|
||||
credentials: { mode: 'none', slots: [] },
|
||||
license: 'LicenseRef-Meldwork-NC',
|
||||
})
|
||||
}
|
||||
|
||||
function instanceFor(record, agent, manifest) {
|
||||
return {
|
||||
instanceId: manifest.connectorId,
|
||||
connectorId: manifest.connectorId,
|
||||
connectorVersion: manifest.connectorVersion,
|
||||
upstreamVersion: BRIDGE_UPSTREAM_VERSION,
|
||||
label: manifest.label,
|
||||
credentialRef: null,
|
||||
manifestId: manifest.manifestId,
|
||||
bridgeId: record.bridgeId,
|
||||
agentId: agent.id,
|
||||
}
|
||||
}
|
||||
|
||||
class CloudAgentBridgeStore {
|
||||
constructor(options = {}) {
|
||||
if (!options || typeof options.storagePath !== 'string' || !options.storagePath
|
||||
|| typeof options.fetch !== 'function') {
|
||||
fail('CLOUD_AGENT_BRIDGE_OPTIONS_INVALID')
|
||||
}
|
||||
this.storagePath = options.storagePath
|
||||
this.fetch = options.fetch
|
||||
this.spawn = options.spawn || spawn
|
||||
this.sshExecute = typeof options.sshExecute === 'function' ? options.sshExecute : null
|
||||
this.sshTunnelStart = typeof options.sshTunnelStart === 'function' ? options.sshTunnelStart : null
|
||||
this.tunnels = new Map()
|
||||
this.timeoutMs = Number.isFinite(options.timeoutMs)
|
||||
? Math.max(1000, Math.min(MAX_REQUEST_TIMEOUT_MS, Math.floor(options.timeoutMs)))
|
||||
: DEFAULT_REQUEST_TIMEOUT_MS
|
||||
this.runTimeoutMs = Number.isFinite(options.runTimeoutMs)
|
||||
? Math.max(1000, Math.min(MAX_REQUEST_TIMEOUT_MS, Math.floor(options.runTimeoutMs)))
|
||||
: DEFAULT_RUN_TIMEOUT_MS
|
||||
this.records = this.read().records
|
||||
}
|
||||
|
||||
read() {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(this.storagePath, 'utf8'))
|
||||
if (parsed?.version !== STORE_VERSION || !Array.isArray(parsed.records)) throw new Error('invalid')
|
||||
return { version: STORE_VERSION, records: parsed.records.map(value => this.normalizeRecord(value)) }
|
||||
} catch {
|
||||
return { version: STORE_VERSION, records: [] }
|
||||
}
|
||||
}
|
||||
|
||||
normalizeRecord(value) {
|
||||
const transport = ['ssh', 'ssh-tunnel'].includes(value?.transport) ? value.transport : 'bridge'
|
||||
const sshAddress = ['ssh', 'ssh-tunnel'].includes(transport) ? normalizeSshAddress(value?.address) : null
|
||||
const address = sshAddress?.address || normalizeAddress(value?.address)
|
||||
const bridgeId = String(value?.bridgeId || bridgeIdFor(`${transport}:${address}`))
|
||||
if (!BRIDGE_ID.test(bridgeId)) fail('CLOUD_AGENT_BRIDGE_STORE_INVALID')
|
||||
const agents = Array.isArray(value?.agents) ? value.agents.map(normalizeAgent).slice(0, MAX_AGENTS) : []
|
||||
return {
|
||||
bridgeId,
|
||||
transport,
|
||||
address,
|
||||
endpoint: transport === 'ssh-tunnel' ? normalizeTunnelEndpoint(value?.endpoint) : '',
|
||||
label: cleanText(value?.label, 120) || (transport === 'ssh' ? address : 'Cloud Agent Server'),
|
||||
serverId: cleanText(value?.serverId, 120),
|
||||
agents,
|
||||
available: transport === 'ssh-tunnel' ? false : value?.available === true,
|
||||
lastError: cleanText(value?.lastError, 120),
|
||||
checkedAt: cleanText(value?.checkedAt, 40),
|
||||
}
|
||||
}
|
||||
|
||||
persist() {
|
||||
atomicWritePrivateFile(this.storagePath, `${JSON.stringify({ version: STORE_VERSION, records: this.records })}\n`)
|
||||
}
|
||||
|
||||
async request(pathname, options = {}) {
|
||||
const controller = new AbortController()
|
||||
const timeoutMs = Number.isFinite(options.timeoutMs)
|
||||
? Math.max(1000, Math.min(MAX_REQUEST_TIMEOUT_MS, Math.floor(options.timeoutMs)))
|
||||
: this.timeoutMs
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
const activityIntervalMs = Math.min(1000, Math.max(10, Math.floor(timeoutMs / 4)))
|
||||
const reportActivity = () => {
|
||||
try { options.onActivity?.() } catch { /* activity reporting must not fail the request */ }
|
||||
}
|
||||
reportActivity()
|
||||
const activityTimer = options.onActivity
|
||||
? setInterval(reportActivity, activityIntervalMs)
|
||||
: null
|
||||
const abortFromCaller = () => controller.abort()
|
||||
options.signal?.addEventListener?.('abort', abortFromCaller, { once: true })
|
||||
try {
|
||||
const response = await this.fetch(`${options.address}${pathname}`, {
|
||||
method: options.method || 'GET',
|
||||
redirect: 'error',
|
||||
signal: controller.signal,
|
||||
headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}) },
|
||||
...(options.body ? { body: JSON.stringify(options.body) } : {}),
|
||||
})
|
||||
if (!response?.ok) throw bridgeError(`CLOUD_AGENT_BRIDGE_HTTP_${response?.status || 'FAILED'}`)
|
||||
const text = await response.text()
|
||||
if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) fail('CLOUD_AGENT_BRIDGE_RESPONSE_TOO_LARGE')
|
||||
try { return JSON.parse(text) } catch { fail('CLOUD_AGENT_BRIDGE_RESPONSE_INVALID') }
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') fail('CLOUD_AGENT_BRIDGE_TIMEOUT')
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
if (activityTimer) clearInterval(activityTimer)
|
||||
options.signal?.removeEventListener?.('abort', abortFromCaller)
|
||||
}
|
||||
}
|
||||
|
||||
async probe(address) {
|
||||
const normalizedAddress = normalizeAddress(address)
|
||||
return normalizeProbe(await this.request('/.well-known/meldwork-agent-bridge', { address: normalizedAddress }))
|
||||
}
|
||||
|
||||
async sshCommand(address, command, input = '', signal) {
|
||||
const parsed = normalizeSshAddress(address)
|
||||
if (signal?.aborted) fail('CLOUD_AGENT_SSH_CANCELLED')
|
||||
if (this.sshExecute) return this.sshExecute({
|
||||
address: parsed.address, command, input, signal,
|
||||
})
|
||||
const args = [
|
||||
'-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=yes',
|
||||
...(parsed.port === 22 ? [] : ['-p', String(parsed.port)]),
|
||||
parsed.host, command,
|
||||
]
|
||||
return new Promise((resolve, reject) => {
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let settled = false
|
||||
const child = this.spawn('ssh', args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
||||
const finish = (error, value) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
signal?.removeEventListener?.('abort', abort)
|
||||
if (error) reject(error)
|
||||
else resolve(value)
|
||||
}
|
||||
const append = (target, chunk) => {
|
||||
const next = `${target}${Buffer.from(chunk).toString('utf8')}`
|
||||
if (Buffer.byteLength(next, 'utf8') > MAX_RESPONSE_BYTES) {
|
||||
child.kill('SIGTERM')
|
||||
finish(bridgeError('CLOUD_AGENT_SSH_RESPONSE_TOO_LARGE'))
|
||||
return target
|
||||
}
|
||||
return next
|
||||
}
|
||||
const abort = () => {
|
||||
child.kill('SIGTERM')
|
||||
finish(bridgeError('CLOUD_AGENT_SSH_CANCELLED'))
|
||||
}
|
||||
child.once('error', () => finish(bridgeError('CLOUD_AGENT_SSH_UNAVAILABLE')))
|
||||
child.stdout.on('data', chunk => { stdout = append(stdout, chunk) })
|
||||
child.stderr.on('data', chunk => { stderr = append(stderr, chunk) })
|
||||
child.once('close', code => {
|
||||
if (signal?.aborted) return finish(bridgeError('CLOUD_AGENT_SSH_CANCELLED'))
|
||||
if (code !== 0) return finish(bridgeError('CLOUD_AGENT_SSH_UNAVAILABLE'))
|
||||
finish(null, { stdout, stderr })
|
||||
})
|
||||
signal?.addEventListener?.('abort', abort, { once: true })
|
||||
child.stdin.end(input)
|
||||
})
|
||||
}
|
||||
|
||||
async allocateLocalPort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const port = server.address()?.port
|
||||
server.close(error => error ? reject(error) : resolve(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async startSshTunnel(address) {
|
||||
const parsed = normalizeSshAddress(address)
|
||||
if (this.sshTunnelStart) return this.sshTunnelStart({ address: parsed.address })
|
||||
const localPort = await this.allocateLocalPort()
|
||||
const remotePort = localPort
|
||||
const args = [
|
||||
'-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=yes',
|
||||
'-L', `127.0.0.1:${localPort}:127.0.0.1:${remotePort}`,
|
||||
...(parsed.port === 22 ? [] : ['-p', String(parsed.port)]),
|
||||
parsed.host, loginShellCommand(`MELDWORK_AGENT_BRIDGE_PORT=${remotePort} exec node -`),
|
||||
]
|
||||
const child = this.spawn('ssh', args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
||||
child.stderr.resume()
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = ''
|
||||
let settled = false
|
||||
const timer = setTimeout(() => finish(bridgeError('CLOUD_AGENT_SSH_TUNNEL_TIMEOUT')), this.timeoutMs)
|
||||
const finish = error => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
if (error) {
|
||||
child.kill('SIGTERM')
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve({ child, endpoint: `http://127.0.0.1:${localPort}` })
|
||||
}
|
||||
child.once('error', () => finish(bridgeError('CLOUD_AGENT_SSH_TUNNEL_UNAVAILABLE')))
|
||||
child.stdout.on('data', chunk => {
|
||||
output += Buffer.from(chunk).toString('utf8')
|
||||
if (Buffer.byteLength(output, 'utf8') > 16 * 1024) {
|
||||
finish(bridgeError('CLOUD_AGENT_SSH_TUNNEL_PROTOCOL'))
|
||||
return
|
||||
}
|
||||
if (new RegExp(`${SSH_TUNNEL_READY}\\s+${remotePort}\\b`).test(output)) finish()
|
||||
})
|
||||
child.once('close', code => {
|
||||
if (!settled) finish(bridgeError(code === 0
|
||||
? 'CLOUD_AGENT_SSH_TUNNEL_UNAVAILABLE'
|
||||
: 'CLOUD_AGENT_SSH_TUNNEL_FAILED'))
|
||||
})
|
||||
child.stdin.end(SSH_BRIDGE_SCRIPT)
|
||||
})
|
||||
}
|
||||
|
||||
stopTunnel(bridgeId) {
|
||||
const tunnel = this.tunnels.get(bridgeId)
|
||||
if (!tunnel) return
|
||||
this.tunnels.delete(bridgeId)
|
||||
try { tunnel.child?.kill('SIGTERM') } catch { /* already closed */ }
|
||||
}
|
||||
|
||||
async ensureSshTunnel(record) {
|
||||
const existing = this.tunnels.get(record.bridgeId)
|
||||
if (existing?.child && !existing.child.killed) return existing.endpoint
|
||||
const tunnel = await this.startSshTunnel(record.address)
|
||||
this.tunnels.set(record.bridgeId, tunnel)
|
||||
tunnel.child?.once?.('close', () => {
|
||||
if (this.tunnels.get(record.bridgeId) === tunnel) this.tunnels.delete(record.bridgeId)
|
||||
})
|
||||
return tunnel.endpoint
|
||||
}
|
||||
|
||||
async probeSsh(address) {
|
||||
const result = await this.sshCommand(address, SSH_DISCOVERY_COMMAND)
|
||||
const version = String(result.stdout || '').match(/\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/)?.[1]
|
||||
if (!version) fail('CLOUD_AGENT_SSH_NO_AGENT')
|
||||
return {
|
||||
serverId: '',
|
||||
serverLabel: normalizeSshAddress(address).address,
|
||||
agents: [{
|
||||
id: 'codex', label: 'Codex CLI', version, description: '', domains: ['general'],
|
||||
inputTypes: ['text'], permissionModes: ['read-only'],
|
||||
session: { supported: false, resume: false, cancel: true, checkpoint: false },
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
async connect(input = {}) {
|
||||
const rawAddress = String(input.address || '').trim()
|
||||
let sshError = null
|
||||
if (!/^[a-z][a-z\d+.-]*:\/\//i.test(rawAddress)) {
|
||||
try { return await this.connectSshTunnel(input) } catch (error) { sshError = error }
|
||||
try { return await this.connectSsh(input) } catch (error) { sshError = error }
|
||||
}
|
||||
try {
|
||||
const address = normalizeAddress(input.address)
|
||||
const probe = await this.probe(address)
|
||||
const bridgeId = bridgeIdFor(address)
|
||||
const record = this.normalizeRecord({
|
||||
bridgeId,
|
||||
address,
|
||||
label: cleanText(input.label, 120) || probe.serverLabel,
|
||||
serverId: probe.serverId,
|
||||
agents: probe.agents,
|
||||
available: true,
|
||||
lastError: '',
|
||||
checkedAt: new Date().toISOString(),
|
||||
})
|
||||
const index = this.records.findIndex(item => item.bridgeId === bridgeId)
|
||||
if (index >= 0) this.records[index] = record
|
||||
else this.records.push(record)
|
||||
this.records.sort((left, right) => left.label.localeCompare(right.label) || left.bridgeId.localeCompare(right.bridgeId))
|
||||
this.persist()
|
||||
return this.publicRecord(record)
|
||||
} catch (error) {
|
||||
throw sshError || error
|
||||
}
|
||||
}
|
||||
|
||||
async connectSsh(input = {}) {
|
||||
const sshAddress = normalizeSshAddress(input.address)
|
||||
const probe = await this.probeSsh(sshAddress.address)
|
||||
const bridgeId = bridgeIdFor(`ssh:${sshAddress.address}`)
|
||||
const record = this.normalizeRecord({
|
||||
bridgeId,
|
||||
transport: 'ssh',
|
||||
address: sshAddress.address,
|
||||
label: cleanText(input.label, 120) || probe.serverLabel,
|
||||
serverId: probe.serverId,
|
||||
agents: probe.agents,
|
||||
available: true,
|
||||
lastError: '',
|
||||
checkedAt: new Date().toISOString(),
|
||||
})
|
||||
const index = this.records.findIndex(item => item.bridgeId === bridgeId)
|
||||
if (index >= 0) this.records[index] = record
|
||||
else this.records.push(record)
|
||||
this.records.sort((left, right) => left.label.localeCompare(right.label) || left.bridgeId.localeCompare(right.bridgeId))
|
||||
this.persist()
|
||||
return this.publicRecord(record)
|
||||
}
|
||||
|
||||
async connectSshTunnel(input = {}) {
|
||||
const sshAddress = normalizeSshAddress(input.address)
|
||||
const existing = this.records.find(record => (
|
||||
record.address === sshAddress.address && ['ssh', 'ssh-tunnel'].includes(record.transport)
|
||||
))
|
||||
const bridgeId = existing?.bridgeId || bridgeIdFor(`ssh-tunnel:${sshAddress.address}`)
|
||||
const endpoint = await this.ensureSshTunnel({ bridgeId, address: sshAddress.address })
|
||||
try {
|
||||
const probe = normalizeProbe(await this.request('/.well-known/meldwork-agent-bridge', { address: endpoint }))
|
||||
const record = this.normalizeRecord({
|
||||
bridgeId,
|
||||
transport: 'ssh-tunnel',
|
||||
address: sshAddress.address,
|
||||
endpoint,
|
||||
label: cleanText(input.label, 120) || probe.serverLabel,
|
||||
serverId: probe.serverId,
|
||||
agents: probe.agents,
|
||||
available: true,
|
||||
lastError: '',
|
||||
checkedAt: new Date().toISOString(),
|
||||
})
|
||||
record.available = true
|
||||
const index = this.records.findIndex(item => item.bridgeId === bridgeId)
|
||||
if (index >= 0) this.records[index] = record
|
||||
else this.records.push(record)
|
||||
this.records = this.records.filter(item => (
|
||||
item.bridgeId === bridgeId || item.address !== sshAddress.address
|
||||
))
|
||||
this.records.sort((left, right) => left.label.localeCompare(right.label) || left.bridgeId.localeCompare(right.bridgeId))
|
||||
this.persist()
|
||||
return this.publicRecord(record)
|
||||
} catch (error) {
|
||||
this.stopTunnel(bridgeId)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
for (const current of this.records) {
|
||||
try {
|
||||
let probe
|
||||
if (current.transport === 'ssh-tunnel') {
|
||||
current.endpoint = await this.ensureSshTunnel(current)
|
||||
probe = normalizeProbe(await this.request('/.well-known/meldwork-agent-bridge', {
|
||||
address: current.endpoint,
|
||||
}))
|
||||
current.available = true
|
||||
} else if (current.transport === 'ssh') {
|
||||
try {
|
||||
current.endpoint = await this.ensureSshTunnel(current)
|
||||
probe = normalizeProbe(await this.request('/.well-known/meldwork-agent-bridge', {
|
||||
address: current.endpoint,
|
||||
}))
|
||||
current.transport = 'ssh-tunnel'
|
||||
current.available = true
|
||||
} catch {
|
||||
probe = await this.probeSsh(current.address)
|
||||
}
|
||||
} else {
|
||||
probe = await this.probe(current.address)
|
||||
}
|
||||
Object.assign(current, {
|
||||
serverId: probe.serverId,
|
||||
agents: probe.agents.map(normalizeAgent),
|
||||
available: true,
|
||||
lastError: '', checkedAt: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
if (current.transport === 'ssh-tunnel') this.stopTunnel(current.bridgeId)
|
||||
current.available = false
|
||||
current.lastError = String(error?.code || 'CLOUD_AGENT_BRIDGE_PROBE_FAILED').slice(0, 120)
|
||||
}
|
||||
}
|
||||
this.persist()
|
||||
return this.list()
|
||||
}
|
||||
|
||||
publicRecord(record) {
|
||||
return clone({
|
||||
bridgeId: record.bridgeId, address: record.address, label: record.label,
|
||||
transport: record.transport,
|
||||
serverId: record.serverId, available: record.available,
|
||||
lastError: record.lastError, checkedAt: record.checkedAt,
|
||||
agents: record.agents.map(agent => ({
|
||||
id: agent.id, sourceKind: agent.sourceKind, label: agent.label, version: agent.version,
|
||||
description: agent.description, domains: agent.domains,
|
||||
available: agent.available,
|
||||
credentialState: agent.credentialState,
|
||||
skills: agent.skills,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
list() {
|
||||
return this.records.map(record => this.publicRecord(record))
|
||||
}
|
||||
|
||||
remove(bridgeId) {
|
||||
const id = String(bridgeId || '')
|
||||
const index = this.records.findIndex(record => record.bridgeId === id)
|
||||
if (index < 0) fail('CLOUD_AGENT_BRIDGE_NOT_FOUND')
|
||||
this.stopTunnel(id)
|
||||
this.records.splice(index, 1)
|
||||
this.persist()
|
||||
return { deleted: true, bridgeId: id }
|
||||
}
|
||||
|
||||
connectorEntries() {
|
||||
return this.records.filter(record => record.available).flatMap(record => record.agents
|
||||
.filter(agent => agent.available !== false)
|
||||
.map(agent => {
|
||||
const manifest = manifestFor(record, agent)
|
||||
return { record: clone(record), agent: clone(agent), manifest, instance: instanceFor(record, agent, manifest) }
|
||||
}))
|
||||
}
|
||||
|
||||
catalogEntries() {
|
||||
return this.records.flatMap(record => record.agents.map(agent => ({
|
||||
kind: instanceIdFor(record.bridgeId, agent.id),
|
||||
sourceKind: agent.sourceKind,
|
||||
label: `${agent.label} @ ${record.label}`,
|
||||
name: `${agent.label} @ ${record.label}`,
|
||||
description: agent.description,
|
||||
version: agent.version,
|
||||
installed: true,
|
||||
installSupported: false,
|
||||
installErrorCode: '',
|
||||
providerCompatible: false,
|
||||
providerMode: 'connector',
|
||||
imageAttachmentLimit: 0,
|
||||
custom: false,
|
||||
connector: true,
|
||||
cloud: true,
|
||||
available: record.available && agent.available !== false,
|
||||
credentialState: agent.credentialState,
|
||||
serverLabel: record.label,
|
||||
})))
|
||||
}
|
||||
|
||||
skillsForInstance(instanceId) {
|
||||
const id = String(instanceId || '')
|
||||
for (const record of this.records) {
|
||||
const agent = record.agents.find(item => instanceIdFor(record.bridgeId, item.id) === id)
|
||||
if (agent) return clone(agent.skills)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async run(input) {
|
||||
const bridgeId = String(input?.bridgeId || '')
|
||||
const agentId = String(input?.agentId || '')
|
||||
const record = this.records.find(item => item.bridgeId === bridgeId && item.available)
|
||||
if (!record || !PUBLIC_ID.test(agentId)) fail('CLOUD_AGENT_BRIDGE_NOT_FOUND')
|
||||
const agent = record.agents.find(item => item.id === agentId)
|
||||
if (!agent) fail('CLOUD_AGENT_BRIDGE_AGENT_NOT_FOUND')
|
||||
if (agent.available === false) fail('CLOUD_AGENT_BRIDGE_AGENT_UNAVAILABLE')
|
||||
if (record.transport === 'ssh') return this.runSsh(record, agent, input)
|
||||
return this.request(`/v1/agents/${encodeURIComponent(agentId)}/runs`, {
|
||||
address: record.transport === 'ssh-tunnel' ? record.endpoint : record.address,
|
||||
method: 'POST',
|
||||
signal: input.signal,
|
||||
timeoutMs: this.runTimeoutMs,
|
||||
onActivity: input.onActivity,
|
||||
body: {
|
||||
prompt: input.prompt,
|
||||
sessionRef: input.sessionRef || null,
|
||||
permissionMode: input.permissionMode,
|
||||
operationId: input.operationId,
|
||||
resume: input.resume || null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async runSsh(record, agent, input) {
|
||||
if (agent.id !== 'codex' || input.permissionMode !== 'read-only') {
|
||||
fail('CLOUD_AGENT_SSH_PERMISSION_UNSUPPORTED')
|
||||
}
|
||||
const result = await this.sshCommand(record.address, SSH_CODEX_COMMAND, input.prompt, input.signal)
|
||||
const messages = []
|
||||
let sessionRef = ''
|
||||
for (const line of String(result.stdout || '').split('\n')) {
|
||||
try {
|
||||
const event = JSON.parse(line)
|
||||
if (event.type === 'thread.started' && typeof event.thread_id === 'string') sessionRef = event.thread_id
|
||||
if (event.type === 'item.completed' && event.item?.type === 'agent_message'
|
||||
&& typeof event.item.text === 'string') messages.push(event.item.text)
|
||||
} catch { /* SSH transport permits only Codex JSONL output */ }
|
||||
}
|
||||
const text = messages.join('\n').trim()
|
||||
if (!text) fail('CLOUD_AGENT_SSH_RESPONSE_INVALID')
|
||||
return { text, sessionRef: '', outcome: 'completed' }
|
||||
}
|
||||
|
||||
bridgeForInstance(instanceId) {
|
||||
for (const entry of this.connectorEntries()) {
|
||||
if (entry.instance.instanceId === instanceId) return entry
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
close() {
|
||||
for (const bridgeId of this.tunnels.keys()) this.stopTunnel(bridgeId)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BRIDGE_RECIPE_ID,
|
||||
SSH_RECIPE_ID,
|
||||
CloudAgentBridgeStore,
|
||||
loginShellCommand,
|
||||
manifestFor,
|
||||
normalizeAddress,
|
||||
normalizeProbe,
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const {
|
||||
MAX_PACKAGE_BYTES,
|
||||
createAgentConnectorPackage,
|
||||
} = require('./agent-connector-package-store.cjs')
|
||||
const { BRIDGE_RECIPE_ID, SSH_RECIPE_ID } = require('../cloud/cloud-agent-bridge.cjs')
|
||||
|
||||
const LOCAL_DELEGATE_RECIPE_ID = 'external.local-agent.delegate'
|
||||
const MAX_LOCAL_MANIFESTS = 64
|
||||
@@ -137,7 +138,7 @@ function fail(code) {
|
||||
function exactOptions(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
&& Object.keys(value).every(key => [
|
||||
'fetch', 'instanceStore', 'manifestDirectory', 'packageStore', 'runAgent', 'seedSample',
|
||||
'cloudBridges', 'fetch', 'instanceStore', 'manifestDirectory', 'packageStore', 'runAgent', 'seedSample',
|
||||
].includes(key))
|
||||
}
|
||||
|
||||
@@ -240,6 +241,7 @@ class LocalAgentConnectors {
|
||||
this.upstreams = new Map()
|
||||
this.detectedUpstreams = []
|
||||
this.lastDiagnostics = []
|
||||
this.cloudBridges = options.cloudBridges || null
|
||||
}
|
||||
|
||||
createRegistry() {
|
||||
@@ -247,7 +249,7 @@ class LocalAgentConnectors {
|
||||
? this.packageStore.installedPackages().map(item => item.manifest)
|
||||
: []
|
||||
return new AgentConnectorRegistry({
|
||||
approvedRecipeIds: [LOCAL_DELEGATE_RECIPE_ID, ...SDK_RECIPE_IDS],
|
||||
approvedRecipeIds: [LOCAL_DELEGATE_RECIPE_ID, BRIDGE_RECIPE_ID, SSH_RECIPE_ID, ...SDK_RECIPE_IDS],
|
||||
approvedExternalManifestIds: [
|
||||
...APPROVED_AGENT_CONNECTOR_MANIFESTS.map(item => item.manifestId),
|
||||
...installedPackageManifests.map(item => item.manifestId),
|
||||
@@ -262,6 +264,8 @@ class LocalAgentConnectors {
|
||||
[LOCAL_DELEGATE_RECIPE_ID]: input => this.runDelegated(input),
|
||||
[SDK_LOCAL_ECHO_RECIPE_ID]: input => this.runLocalEcho(input),
|
||||
[SDK_HTTP_JSON_RECIPE_ID]: input => this.runHttpJson(input),
|
||||
[BRIDGE_RECIPE_ID]: input => this.runCloudBridge(input),
|
||||
[SSH_RECIPE_ID]: input => this.runCloudBridge(input),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -350,6 +354,29 @@ class LocalAgentConnectors {
|
||||
diagnostics.push({ name: 'instances', code: safeFailureCode(error) })
|
||||
}
|
||||
const manifests = registry.listManifests()
|
||||
for (const entry of this.cloudBridges?.connectorEntries?.() || []) {
|
||||
try {
|
||||
const manifest = registry.registerBuiltin(entry.manifest)
|
||||
const instance = registry.registerInstance({
|
||||
instanceId: entry.instance.instanceId,
|
||||
connectorId: manifest.connectorId,
|
||||
connectorVersion: manifest.connectorVersion,
|
||||
upstreamVersion: entry.instance.upstreamVersion,
|
||||
label: entry.instance.label,
|
||||
credentialRef: null,
|
||||
})
|
||||
upstreams.set(instance.instanceId, Object.freeze({
|
||||
kind: instance.instanceId,
|
||||
label: instance.label,
|
||||
name: instance.label,
|
||||
version: instance.upstreamVersion,
|
||||
compatibilityState: 'compatible',
|
||||
cloud: true,
|
||||
}))
|
||||
} catch (error) {
|
||||
diagnostics.push({ name: entry.instance.instanceId, code: safeFailureCode(error) })
|
||||
}
|
||||
}
|
||||
for (const stored of storedInstances) {
|
||||
const manifest = manifests.find(item => (
|
||||
item.manifestId === stored.manifestId
|
||||
@@ -405,9 +432,10 @@ class LocalAgentConnectors {
|
||||
|
||||
detectAgents() {
|
||||
return this.runtime.detectAgents().map((agent) => {
|
||||
const { instance } = this.registry.resolveInstance(agent.connectorInstanceId)
|
||||
const { instance, manifest } = this.registry.resolveInstance(agent.connectorInstanceId)
|
||||
return Object.freeze({
|
||||
...agent,
|
||||
cloud: [BRIDGE_RECIPE_ID, SSH_RECIPE_ID].includes(manifest.invocation.recipeId),
|
||||
credentialConfigured: Boolean(instance.credentialRef),
|
||||
})
|
||||
}).sort((left, right) => (
|
||||
@@ -563,20 +591,26 @@ class LocalAgentConnectors {
|
||||
catalog() {
|
||||
return this.detectAgents().map((agent) => {
|
||||
const { manifest } = this.registry.resolveInstance(agent.connectorInstanceId)
|
||||
const cloud = [BRIDGE_RECIPE_ID, SSH_RECIPE_ID].includes(manifest.invocation.recipeId)
|
||||
const cloudEntry = cloud ? this.cloudBridges?.bridgeForInstance?.(agent.kind) : null
|
||||
return Object.freeze({
|
||||
kind: agent.kind,
|
||||
sourceKind: cloudEntry?.agent?.sourceKind || '',
|
||||
label: agent.label,
|
||||
name: agent.name,
|
||||
description: manifest.description,
|
||||
version: agent.version,
|
||||
version: cloudEntry?.agent?.version || agent.version,
|
||||
installed: true,
|
||||
installSupported: false,
|
||||
installErrorCode: '',
|
||||
providerCompatible: false,
|
||||
providerMode: 'connector',
|
||||
imageAttachmentLimit: manifest.inputTypes.includes('image') ? 32 : 0,
|
||||
custom: true,
|
||||
custom: !cloud,
|
||||
connector: true,
|
||||
cloud,
|
||||
credentialState: cloudEntry?.agent?.credentialState || 'unknown',
|
||||
serverLabel: cloudEntry?.record?.label || '',
|
||||
connectorId: manifest.connectorId,
|
||||
connectorVersion: manifest.connectorVersion,
|
||||
upstreamVersion: agent.upstreamVersion,
|
||||
@@ -768,6 +802,64 @@ class LocalAgentConnectors {
|
||||
return { outcome: 'failed', failure }
|
||||
}
|
||||
}
|
||||
|
||||
async runCloudBridge(input) {
|
||||
const entry = this.cloudBridges?.bridgeForInstance?.(input.connector.instanceId)
|
||||
if (!entry) fail('CLOUD_AGENT_BRIDGE_NOT_FOUND')
|
||||
const terminal = {
|
||||
eventId: `${input.agentRunId}:terminal`,
|
||||
cursor: `${input.agentRunId}:terminal`,
|
||||
sequence: 1,
|
||||
}
|
||||
try {
|
||||
if (!['ssh', 'ssh-tunnel'].includes(entry.record.transport)) {
|
||||
input.onOutboundPayload({ destination: entry.record.address, transport: 'http' })
|
||||
}
|
||||
const result = await this.cloudBridges.run({
|
||||
bridgeId: entry.record.bridgeId,
|
||||
agentId: entry.agent.id,
|
||||
prompt: input.prompt,
|
||||
sessionRef: input.sessionRef,
|
||||
// Cloud bridge tasks never receive local workspace write authority.
|
||||
permissionMode: 'read-only',
|
||||
operationId: input.operationId,
|
||||
resume: input.resume,
|
||||
signal: input.signal,
|
||||
onActivity: input.onActivity,
|
||||
})
|
||||
if (!result || typeof result !== 'object' || Array.isArray(result)
|
||||
|| typeof result.text !== 'string' || result.text.includes('\u0000')
|
||||
|| (result.sessionRef !== undefined
|
||||
&& (typeof result.sessionRef !== 'string' || result.sessionRef.length > 8192
|
||||
|| /[\u0000-\u001f\u007f]/.test(result.sessionRef)))
|
||||
|| !['completed', 'partial', 'failed', 'cancelled'].includes(result.outcome || 'completed')) {
|
||||
fail('CLOUD_AGENT_BRIDGE_RESPONSE_INVALID')
|
||||
}
|
||||
if (result.outcome === 'failed') {
|
||||
const failure = failureDetails(result.failure || result)
|
||||
input.emit({ ...terminal, type: 'Failed', ...failure })
|
||||
return { outcome: 'failed', failure }
|
||||
}
|
||||
if (result.outcome === 'cancelled') {
|
||||
input.emit({ ...terminal, type: 'Cancelled', reason: 'user' })
|
||||
return { outcome: 'cancelled', sessionRef: result.sessionRef || input.sessionRef || '' }
|
||||
}
|
||||
input.emit({ ...terminal, type: 'Completed', outcome: result.outcome || 'completed' })
|
||||
return {
|
||||
text: result.text,
|
||||
sessionRef: typeof result.sessionRef === 'string' ? result.sessionRef : input.sessionRef || '',
|
||||
outcome: result.outcome || 'completed',
|
||||
}
|
||||
} catch (error) {
|
||||
if (input.signal?.aborted || error?.name === 'AbortError') {
|
||||
input.emit({ ...terminal, type: 'Cancelled', reason: 'user' })
|
||||
return { outcome: 'cancelled', sessionRef: input.sessionRef || '' }
|
||||
}
|
||||
const failure = failureDetails(error)
|
||||
input.emit({ ...terminal, type: 'Failed', ...failure })
|
||||
return { outcome: 'failed', failure }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -53,6 +53,12 @@ const NPM_RELEASES = Object.freeze({
|
||||
version: '2026.7.1-2',
|
||||
integrity: 'sha512-ycF3yPcbjN6bUPeaUx6Mh6vze1hQWoD3CT/wWcmD7a8xaHHHRUaAlaq+lFxMHf1ssEgODVAwjlzYqp2twkYZ7g==',
|
||||
}),
|
||||
pi: Object.freeze({
|
||||
packageName: '@earendil-works/pi-coding-agent',
|
||||
version: '0.84.2',
|
||||
integrity: 'sha512-l4E+B7hgXKWddRo8bC/eSue2aWZjEgJ9xIpf5p0Og+lq8a2TArCwJ0HCoCPCgaBP/tN4zbYH/wOwvx9pJpeLCA==',
|
||||
ignoreScripts: true,
|
||||
}),
|
||||
qwen: Object.freeze({
|
||||
packageName: '@qwen-code/qwen-code',
|
||||
version: '0.21.5',
|
||||
@@ -93,17 +99,37 @@ const NPM_RELEASES = Object.freeze({
|
||||
const ALLOWED_SCRIPT_URLS = new Set(Object.values(HERMES_RECIPES).map(recipe => recipe.url))
|
||||
|
||||
const AGENT_CATALOG = Object.freeze([
|
||||
{
|
||||
kind: 'codex', label: 'Codex', recommended: false, providerCompatible: false,
|
||||
providerSupport: 'responses-required',
|
||||
},
|
||||
{
|
||||
kind: 'claude', label: 'Claude Code', recommended: false, providerCompatible: false,
|
||||
providerSupport: 'anthropic-required',
|
||||
},
|
||||
{
|
||||
kind: 'hermes', label: 'Hermes', recommended: true, providerCompatible: true,
|
||||
providerSupport: 'supported',
|
||||
},
|
||||
{
|
||||
kind: 'workbuddy', label: 'WorkBuddy', recommended: true, providerCompatible: true,
|
||||
providerSupport: 'experimental',
|
||||
},
|
||||
{
|
||||
kind: 'openclaw', label: 'OpenClaw', recommended: true, providerCompatible: true,
|
||||
providerSupport: 'supported',
|
||||
},
|
||||
{
|
||||
kind: 'workbuddy', label: 'WorkBuddy', recommended: true, providerCompatible: true,
|
||||
providerSupport: 'experimental',
|
||||
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,
|
||||
@@ -113,30 +139,10 @@ const AGENT_CATALOG = Object.freeze([
|
||||
kind: 'mimo', label: 'MiMo Code', recommended: false, providerCompatible: false,
|
||||
providerSupport: 'native-config',
|
||||
},
|
||||
{
|
||||
kind: 'codex', label: 'Codex', recommended: false, providerCompatible: false,
|
||||
providerSupport: 'responses-required',
|
||||
},
|
||||
{
|
||||
kind: 'claude', label: 'Claude Code', recommended: false, providerCompatible: false,
|
||||
providerSupport: 'anthropic-required',
|
||||
},
|
||||
{
|
||||
kind: 'gemini', label: 'Gemini CLI', recommended: false, providerCompatible: false,
|
||||
providerSupport: 'native-config',
|
||||
},
|
||||
{
|
||||
kind: 'opencode', label: 'OpenCode', recommended: false, providerCompatible: false,
|
||||
providerSupport: 'native-config',
|
||||
},
|
||||
{
|
||||
kind: 'qwen', label: 'Qwen Code', recommended: false, providerCompatible: true,
|
||||
providerSupport: 'supported',
|
||||
},
|
||||
{
|
||||
kind: 'opencodereview', label: 'OpenCodeReview', recommended: false, providerCompatible: true,
|
||||
providerSupport: 'supported',
|
||||
},
|
||||
])
|
||||
|
||||
function installRecipe(kind, platform) {
|
||||
|
||||
@@ -295,7 +295,9 @@ class AgentInstaller extends EventEmitter {
|
||||
throw installerError('INSTALL_AGENT_INTEGRITY_FAILED')
|
||||
}
|
||||
args = [
|
||||
'install', '--global', npmPackageSpec(recipe),
|
||||
'install', '--global',
|
||||
...(recipe.ignoreScripts ? ['--ignore-scripts'] : []),
|
||||
npmPackageSpec(recipe),
|
||||
'--registry', NPM_REGISTRY,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ const CREDENTIAL_ENV_KEYS = Object.freeze({
|
||||
hermes: ['OPENAI_API_KEY', 'OPENROUTER_API_KEY', 'ANTHROPIC_API_KEY'],
|
||||
openclaw: ['OPENAI_API_KEY', 'OPENROUTER_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'],
|
||||
@@ -52,6 +53,10 @@ const RUNTIME_ENV_KEYS = Object.freeze({
|
||||
'CODEBUDDY_API_KEY', 'CODEBUDDY_BASE_URL', 'CODEBUDDY_MODEL',
|
||||
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', 'ANTHROPIC_API_KEY',
|
||||
],
|
||||
pi: [
|
||||
'PI_API_KEY', 'PI_BASE_URL', 'PI_MODEL',
|
||||
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', 'ANTHROPIC_API_KEY',
|
||||
],
|
||||
kimi: [
|
||||
'MOONSHOT_API_KEY', 'KIMI_API_KEY', 'KIMI_MODEL_API_KEY',
|
||||
'KIMI_MODEL_BASE_URL', 'KIMI_MODEL_NAME',
|
||||
@@ -252,6 +257,12 @@ function nativeCredentialState(kind, options = {}) {
|
||||
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(home, '.pi', 'auth.json'),
|
||||
path.join(home, '.pi', 'config.json'),
|
||||
path.join(home, '.pi-agent', 'auth.json'),
|
||||
path.join(home, '.pi-agent', 'config.json'),
|
||||
],
|
||||
kimi: [path.join(home, '.kimi-code', 'credentials', 'kimi-code.json')],
|
||||
claude: [
|
||||
path.join(home, '.claude', '.credentials.json'),
|
||||
@@ -279,6 +290,12 @@ function nativeCredentialState(kind, options = {}) {
|
||||
],
|
||||
openclaw: [path.join(home, '.openclaw', '.env')],
|
||||
workbuddy: [path.join(home, '.workbuddy', '.env')],
|
||||
pi: [
|
||||
path.join(home, '.pi', '.env'),
|
||||
path.join(home, '.pi', 'config.toml'),
|
||||
path.join(home, '.pi-agent', '.env'),
|
||||
path.join(home, '.pi-agent', 'config.toml'),
|
||||
],
|
||||
kimi: [
|
||||
path.join(home, '.kimi', 'config.toml'),
|
||||
path.join(home, '.kimi-code', 'config.toml'),
|
||||
|
||||
@@ -90,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')
|
||||
@@ -205,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,
|
||||
@@ -212,6 +224,7 @@ function createAttachmentService({ getStore, getSnapshot, nativeImage, openPath
|
||||
importFiles,
|
||||
normalizePickLimit: normalizeAttachmentPickLimit,
|
||||
open,
|
||||
save,
|
||||
preview,
|
||||
registerProtocol: protocol => protocol?.handle?.(MEDIA_SCHEME, request => mediaResponse(request)),
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ const V4_TEMPLATES = new Set(['concurrent-batch', 'discussion'])
|
||||
const V4_WORKFLOWS = new Set(['manual', 'auto', 'concurrent-batch', 'discussion'])
|
||||
const V4_PHASES = new Set([
|
||||
'prepare', 'dispatch', 'running', 'reconcile',
|
||||
'proposal', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
|
||||
'proposal', 'discussion', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
|
||||
'commit', 'committed', 'completed', 'failed', 'stopped', 'human-gate',
|
||||
])
|
||||
const V4_SLOT_STATUSES = new Set([
|
||||
@@ -60,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', 'batchId', 'round',
|
||||
'version', 'workflow', 'template', 'phase', 'discussionStyle', 'batchId', 'round',
|
||||
'currentKind', 'currentKinds', 'pendingKinds', 'activeKinds',
|
||||
'successfulKinds', 'agreementKinds', 'attachmentRecipients',
|
||||
'totalSuccesses', 'terminalFailureOccurred', 'collaboration', 'taskGraph',
|
||||
@@ -347,13 +347,13 @@ function createCoordinationPlan(input = {}) {
|
||||
}
|
||||
const finalizerKind = cleanId(input.finalizerKind)
|
||||
const verifierKinds = uniqueIds(
|
||||
input.verifierKinds || [], Math.min(2, MAX_V4_SLOTS), true,
|
||||
input.verifierKinds || [], MAX_V4_SLOTS, true,
|
||||
).sort()
|
||||
const agreedBy = uniqueIds(input.agreedBy || [], MAX_V4_SLOTS, true).sort()
|
||||
const supportReceiptIds = input.supportReceiptIds == null
|
||||
? null
|
||||
: uniqueIds(input.supportReceiptIds, MAX_V4_SLOTS, true).sort()
|
||||
const requiredVerifierCount = Math.min(2, targetKinds.length - 1)
|
||||
const requiredVerifierCount = targetKinds.length - 1
|
||||
if (!finalizerKind || !targetKinds.includes(finalizerKind)
|
||||
|| verifierKinds.length !== requiredVerifierCount
|
||||
|| verifierKinds.some(kind => !targetKinds.includes(kind) || kind === finalizerKind)
|
||||
@@ -1784,6 +1784,14 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
}
|
||||
const { workflow, template } = normalizeWorkflow(input.workflow, input.template)
|
||||
const phase = phaseForTemplate(template, String(input.phase || ''))
|
||||
const discussionStyle = hasOwn(input, 'discussionStyle')
|
||||
? String(input.discussionStyle || '')
|
||||
: ''
|
||||
if (discussionStyle && (
|
||||
workflow !== 'auto'
|
||||
|| template !== 'discussion'
|
||||
|| !['sequential', 'agent-led'].includes(discussionStyle)
|
||||
)) fail('ORCHESTRATION_V4_SCHEMA_INVALID')
|
||||
const batchId = cleanId(input.batchId)
|
||||
const snapshotHash = String(input.snapshotHash || '')
|
||||
if (!batchId || !SHA256.test(snapshotHash)) fail('ORCHESTRATION_V4_SCHEMA_INVALID')
|
||||
@@ -1831,6 +1839,15 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
const planKinds = plan.assignments.map(assignment => assignment.agentKind)
|
||||
const effectiveKinds = targetKinds || [...new Set([...inferredKinds, ...planKinds])]
|
||||
if (currentKinds.some(kind => !effectiveKinds.includes(kind))) fail('ORCHESTRATION_V4_REFERENCE_INVALID')
|
||||
const activeKinds = hasOwn(input, 'activeKinds')
|
||||
? uniqueIds(input.activeKinds, MAX_V4_SLOTS)
|
||||
: effectiveKinds
|
||||
if (activeKinds.some(kind => !effectiveKinds.includes(kind))) {
|
||||
fail('ORCHESTRATION_V4_REFERENCE_INVALID')
|
||||
}
|
||||
const participantKinds = workflow === 'auto' && template === 'discussion'
|
||||
? activeKinds
|
||||
: effectiveKinds
|
||||
if (!Array.isArray(input.slots) || input.slots.length > MAX_V4_SLOTS) {
|
||||
fail('ORCHESTRATION_V4_SLOT_INVALID')
|
||||
}
|
||||
@@ -1865,26 +1882,26 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
fail('ORCHESTRATION_V4_DELIVERY_STATE_INVALID')
|
||||
}
|
||||
const challengeBindings = hasOwn(input, 'challengeBindings')
|
||||
? normalizeChallengeBindings(input.challengeBindings, effectiveKinds, input.round)
|
||||
? normalizeChallengeBindings(input.challengeBindings, participantKinds, input.round)
|
||||
: null
|
||||
if (challengeBindings && (workflow !== 'auto' || template !== 'discussion')) {
|
||||
fail('ORCHESTRATION_V4_CHALLENGE_BINDING_INVALID')
|
||||
}
|
||||
const synthesisBinding = hasOwn(input, 'synthesisBinding')
|
||||
? normalizeSynthesisBinding(input.synthesisBinding, effectiveKinds, snapshot?.bodyHash)
|
||||
? normalizeSynthesisBinding(input.synthesisBinding, participantKinds, snapshot?.bodyHash)
|
||||
: null
|
||||
if (synthesisBinding && (workflow !== 'auto' || template !== 'discussion')) {
|
||||
fail('ORCHESTRATION_V4_SYNTHESIS_BINDING_INVALID')
|
||||
}
|
||||
const coordinationPlan = hasOwn(input, 'coordinationPlan')
|
||||
? normalizeCoordinationPlan(input.coordinationPlan, effectiveKinds, snapshotHash)
|
||||
? normalizeCoordinationPlan(input.coordinationPlan, participantKinds, snapshotHash)
|
||||
: null
|
||||
if (coordinationPlan && (workflow !== 'auto' || template !== 'discussion')) {
|
||||
fail('ORCHESTRATION_V4_COORDINATION_PLAN_INVALID')
|
||||
}
|
||||
if (coordinationPlan) {
|
||||
validateCoordinationAgreement(
|
||||
coordinationPlan, slots, challengeBindings, effectiveKinds, snapshotHash,
|
||||
coordinationPlan, slots, challengeBindings, participantKinds, snapshotHash,
|
||||
)
|
||||
}
|
||||
const workReceipts = hasOwn(input, 'workReceipts')
|
||||
@@ -1901,7 +1918,7 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
}
|
||||
const synthesisRecovery = hasOwn(input, 'synthesisRecovery')
|
||||
? normalizeSynthesisRecovery(
|
||||
input.synthesisRecovery, effectiveKinds, synthesisBinding, coordinationPlan, input.round,
|
||||
input.synthesisRecovery, participantKinds, synthesisBinding, coordinationPlan, input.round,
|
||||
)
|
||||
: null
|
||||
if (synthesisRecovery && (workflow !== 'auto' || template !== 'discussion')) {
|
||||
@@ -1915,7 +1932,7 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
fail('ORCHESTRATION_V4_CONVERGENCE_INVALID')
|
||||
}
|
||||
const hasCompletedChallengeEvidence = completedChallengeEvidence(
|
||||
slots, effectiveKinds, snapshotHash, challengeBindings,
|
||||
slots, participantKinds, snapshotHash, challengeBindings,
|
||||
)
|
||||
const synthesisObservable = workflow === 'auto' && template === 'discussion'
|
||||
&& (synthesisStateObservable(phase, slots)
|
||||
@@ -1924,10 +1941,12 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
const completedChallengeCheckpoint = phase === 'challenge'
|
||||
&& currentKinds.length === 0
|
||||
&& (!Array.isArray(input.pendingKinds) || input.pendingKinds.length === 0)
|
||||
&& slots.every(slot => slot.status === 'completed')
|
||||
&& participantKinds.every(kind => slots.some(slot => (
|
||||
slot.agentKind === kind && slot.status === 'completed'
|
||||
)))
|
||||
const retainedChallengeBinding = phase === 'challenge'
|
||||
&& completedChallengeEvidence(
|
||||
slots, effectiveKinds, snapshotHash, challengeBindings, deliveryWatermarks, true,
|
||||
slots, participantKinds, snapshotHash, challengeBindings, deliveryWatermarks, true,
|
||||
)
|
||||
&& postChallengeReceiptObservable(slots, deliveryWatermarks, snapshotHash)
|
||||
if (synthesisBinding && (
|
||||
@@ -2006,8 +2025,10 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
workflow,
|
||||
template,
|
||||
phase,
|
||||
...(discussionStyle ? { discussionStyle } : {}),
|
||||
batchId,
|
||||
currentKinds,
|
||||
activeKinds,
|
||||
snapshotHash,
|
||||
...(snapshot ? { snapshot } : {}),
|
||||
plan,
|
||||
@@ -2024,7 +2045,7 @@ function parseOrchestrationV4(input, options = {}) {
|
||||
...(workReceipts ? { workReceipts } : {}),
|
||||
}
|
||||
for (const key of [
|
||||
'round', 'currentKind', 'pendingKinds', 'activeKinds', 'successfulKinds',
|
||||
'round', 'currentKind', 'pendingKinds', 'successfulKinds',
|
||||
'agreementKinds', 'attachmentRecipients', 'totalSuccesses', 'terminalFailureOccurred',
|
||||
'collaboration', 'taskGraph',
|
||||
]) {
|
||||
@@ -2203,7 +2224,7 @@ function parseCollaborationControlBlock(input, options = {}) {
|
||||
}
|
||||
})
|
||||
const finalizerKind = input.finalizerKind == null ? '' : safeText(input.finalizerKind, 120, false)
|
||||
const verifierKinds = boundedList(input.verifierKinds, Math.min(2, MAX_V4_SLOTS), 120)
|
||||
const verifierKinds = boundedList(input.verifierKinds, MAX_V4_SLOTS, 120)
|
||||
const supportedPlanHash = input.supportedPlanHash == null
|
||||
? '' : String(input.supportedPlanHash)
|
||||
const workItemId = input.workItemId == null ? '' : safeText(input.workItemId, 120, false)
|
||||
|
||||
+25
-48
@@ -29,6 +29,7 @@ const { AgentConnectorInstanceStore } = require('./agents/connectors/agent-conne
|
||||
const { LocalAgentConnectors } = require('./agents/connectors/agent-connector-local.cjs')
|
||||
const { AgentConnectorPackageStore } = require('./agents/connectors/agent-connector-package-store.cjs')
|
||||
const { CloudAgentOperationStore } = require('./agents/cloud/cloud-agent-operation-store.cjs')
|
||||
const { CloudAgentBridgeStore } = require('./agents/cloud/cloud-agent-bridge.cjs')
|
||||
const {
|
||||
CloudAgentRuntime,
|
||||
createCloudAgentStartRetry = ({ runtime }) => Object.freeze({
|
||||
@@ -46,8 +47,6 @@ const {
|
||||
resolveNativeShellEnvironment,
|
||||
} = require('./agents/local-agent-readiness.cjs')
|
||||
const { LocalWorkspace } = require('./workspace/local-workspace.cjs')
|
||||
const { MediaGenerationRuntime } = require('./media/media-generation-runtime.cjs')
|
||||
const { resolveMediaProvider } = require('./media/media-provider-resolution.cjs')
|
||||
const { registerDesktopIpc } = require('./shell/main-ipc.cjs')
|
||||
const {
|
||||
isAllowedExternalUrl,
|
||||
@@ -77,7 +76,7 @@ const {
|
||||
providerOptionsFor,
|
||||
} = require('./providers/provider-options.cjs')
|
||||
const LOCAL_AGENT_KINDS = new Set([
|
||||
'codex', 'hermes', 'openclaw', 'workbuddy', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen',
|
||||
'codex', 'hermes', 'openclaw', 'workbuddy', 'pi', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen',
|
||||
'opencodereview',
|
||||
])
|
||||
const QUIT_CLEANUP_TIMEOUT_MS = 2500
|
||||
@@ -92,6 +91,7 @@ let providerStore = null
|
||||
let customAgentStore = null
|
||||
let agentConnectors = null
|
||||
let cloudAgentRuntime = null
|
||||
let cloudAgentBridges = null
|
||||
let cloudAgentStartRetry = null
|
||||
let channelIngressRuntime = null
|
||||
let knowledgeBaseStore = null
|
||||
@@ -133,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?.([{
|
||||
@@ -251,37 +252,6 @@ async function detectLocalAgents() {
|
||||
return detectAgents({ env: shellEnvironment.env, home: shellEnvironment.home })
|
||||
}
|
||||
|
||||
function mediaFallbackProviders(env = process.env) {
|
||||
const apiKey = String(
|
||||
env.ZGCI_MEDIA_API_KEY
|
||||
|| env.ZGCI_API_KEY
|
||||
|| env.ZCGI_API_KEY
|
||||
|| env.ZGCI_LLM_API_KEY
|
||||
|| '',
|
||||
).trim()
|
||||
if (!apiKey) return []
|
||||
const baseUrl = String(env.ZGCI_MEDIA_BASE_URL || 'https://hub.zgci.org/v1')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
.replace(/\/chat\/completions$/i, '')
|
||||
let parsed
|
||||
try { parsed = new URL(baseUrl) } catch { return [] }
|
||||
if (parsed.protocol !== 'https:' || parsed.username || parsed.password
|
||||
|| parsed.search || parsed.hash) {
|
||||
return []
|
||||
}
|
||||
return [{
|
||||
kind: 'zgci-media',
|
||||
status: {
|
||||
configured: true,
|
||||
provider: 'ZGCI Media',
|
||||
baseUrl,
|
||||
model: 'glm',
|
||||
},
|
||||
credentials: { OPENAI_API_KEY: apiKey },
|
||||
}]
|
||||
}
|
||||
|
||||
function workspaceStoragePath(userData = app.getPath('userData')) {
|
||||
return path.join(userData, 'meldwork-workspace.json')
|
||||
}
|
||||
@@ -506,6 +476,7 @@ function createWorkspace() {
|
||||
safeStorage: lazySafeStorage,
|
||||
}),
|
||||
runAgent: runCoreAgent,
|
||||
cloudBridges: cloudAgentBridges,
|
||||
})
|
||||
const outcomeStore = new OutcomeStore({
|
||||
rootPath: path.join(privateRoot, 'outcomes'),
|
||||
@@ -530,17 +501,6 @@ function createWorkspace() {
|
||||
}),
|
||||
connectors: [],
|
||||
})
|
||||
const mediaGeneration = new MediaGenerationRuntime({
|
||||
getProvider: (kind, type, excludedKinds) => resolveMediaProvider({
|
||||
requestedKind: kind,
|
||||
type,
|
||||
kinds: EXTERNAL_PROVIDER_KINDS,
|
||||
excludedKinds,
|
||||
statusFor: candidateKind => providerStore.status(candidateKind),
|
||||
credentialsFor: candidateKind => providerStore.envForAgent(candidateKind),
|
||||
fallbackProviders: mediaFallbackProviders(),
|
||||
}),
|
||||
})
|
||||
const localWorkspace = new LocalWorkspace({
|
||||
storagePath: workspaceStoragePath(),
|
||||
contentBlobStore,
|
||||
@@ -551,6 +511,7 @@ function createWorkspace() {
|
||||
agentFitMatrix: FROZEN_AGENT_FIT_MATRIX,
|
||||
runLedger: cloudAgentRuntime.workspaceLedger(),
|
||||
detectAgents: async () => {
|
||||
await cloudAgentBridges?.refresh()
|
||||
const [installedAgents, customAgents] = await Promise.all([
|
||||
installer.detectedAgents(),
|
||||
customAgentStore.detectAgents(),
|
||||
@@ -584,7 +545,7 @@ function createWorkspace() {
|
||||
agentLabel: kind => customAgentStore.label(kind) || agentConnectors.label(kind),
|
||||
sharedProviderReady: kind => EXTERNAL_PROVIDER_KINDS.has(kind) && providerStore.status(kind).configured,
|
||||
connectorRuntime: agentConnectors,
|
||||
generateMedia: input => mediaGeneration.generate(input),
|
||||
naturalAgentResponses: true,
|
||||
runAgent: async (agent, prompt, workdir, options = {}) => {
|
||||
if (customAgentStore.has(agent.kind)) {
|
||||
return customAgentStore.run(agent.kind, prompt, workdir, {
|
||||
@@ -610,6 +571,7 @@ function createWorkspace() {
|
||||
}
|
||||
|
||||
async function localAgentCatalog() {
|
||||
await cloudAgentBridges?.refresh()
|
||||
const [catalog, customAgents, installedAgents] = await Promise.all([
|
||||
installer.catalog(),
|
||||
customAgentStore.catalog(),
|
||||
@@ -617,10 +579,17 @@ async function localAgentCatalog() {
|
||||
])
|
||||
agentConnectors?.refresh(installedAgents)
|
||||
const connectorAgents = agentConnectors?.catalog() || []
|
||||
const mergedAgents = new Map()
|
||||
for (const agent of [...catalog.agents, ...customAgents, ...connectorAgents]) {
|
||||
mergedAgents.set(agent.kind, agent)
|
||||
}
|
||||
for (const agent of cloudAgentBridges?.catalogEntries?.() || []) {
|
||||
mergedAgents.set(agent.kind, { ...(mergedAgents.get(agent.kind) || {}), ...agent })
|
||||
}
|
||||
const states = new Map((workspace?.snapshot().agents || []).map(agent => [agent.kind, agent]))
|
||||
return {
|
||||
...catalog,
|
||||
agents: [...catalog.agents, ...customAgents, ...connectorAgents].map((agent) => {
|
||||
agents: [...mergedAgents.values()].map((agent) => {
|
||||
const state = states.get(agent.kind)
|
||||
const support = localAttachmentSupport(agent.kind)
|
||||
return {
|
||||
@@ -635,7 +604,9 @@ async function localAgentCatalog() {
|
||||
recentlyVerified: Boolean(agent.installed && state?.recentlyVerified),
|
||||
capabilities: state?.capabilities || agent.capabilities || null,
|
||||
available: Boolean(agent.installed && state?.invocable),
|
||||
credentialState: agent.installed ? (state?.credentialState || 'unknown') : 'missing',
|
||||
credentialState: agent.installed
|
||||
? (state?.credentialState || agent.credentialState || 'unknown')
|
||||
: 'missing',
|
||||
availabilitySource: agent.installed ? (state?.availabilitySource || 'unverified') : 'none',
|
||||
showInSidebar: Boolean(agent.installed && state?.showInSidebar),
|
||||
attachmentTypes: Object.entries(support)
|
||||
@@ -755,6 +726,7 @@ function registerIpc() {
|
||||
getAttachmentStore: () => attachmentStore,
|
||||
getAgentConnectors: () => agentConnectors,
|
||||
getCloudAgentRuntime: () => cloudAgentRuntime,
|
||||
getCloudAgentBridges: () => cloudAgentBridges,
|
||||
getMainWindow: () => mainWindow,
|
||||
getWorkspace: () => workspace,
|
||||
installer,
|
||||
@@ -797,6 +769,10 @@ if (!hasSingleInstanceLock) {
|
||||
customAgentStore = new CustomAgentStore({
|
||||
storagePath: path.join(app.getPath('userData'), 'meldwork-custom-agents.json'),
|
||||
})
|
||||
cloudAgentBridges = new CloudAgentBridgeStore({
|
||||
storagePath: path.join(app.getPath('userData'), 'meldwork-cloud-agent-bridges.json'),
|
||||
fetch: globalThis.fetch,
|
||||
})
|
||||
providerStore = new ProviderStore({
|
||||
storagePath: path.join(app.getPath('userData'), 'meldwork-provider.json'),
|
||||
safeStorage: lazySafeStorage,
|
||||
@@ -871,6 +847,7 @@ if (!hasSingleInstanceLock) {
|
||||
if (installRunning) installer.cancel(installerState.taskId)
|
||||
quitCleanup = runQuitCleanup([
|
||||
() => channelIngressRuntime?.shutdown(),
|
||||
() => cloudAgentBridges?.close(),
|
||||
() => cloudAgentRuntime?.shutdown(),
|
||||
async () => {
|
||||
try {
|
||||
|
||||
@@ -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 }
|
||||
@@ -117,6 +117,12 @@ if (isLocalDocument) Object.assign(desktopApi, {
|
||||
),
|
||||
cancel: runId => ipcRenderer.invoke('local-cloud-agent:cancel', runId),
|
||||
}),
|
||||
cloudAgentBridge: Object.freeze({
|
||||
list: () => ipcRenderer.invoke('local-cloud-agent-bridge:list'),
|
||||
connect: input => ipcRenderer.invoke('local-cloud-agent-bridge:connect', input),
|
||||
refresh: () => ipcRenderer.invoke('local-cloud-agent-bridge:refresh'),
|
||||
delete: bridgeId => ipcRenderer.invoke('local-cloud-agent-bridge:delete', bridgeId),
|
||||
}),
|
||||
localOutcome: Object.freeze({
|
||||
recordAdoption: input => ipcRenderer.invoke('local-outcome:record-adoption', input),
|
||||
}),
|
||||
@@ -168,6 +174,7 @@ 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({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const { managedOpenClawOptions, nativeOpenClawOptions } = require('../agents/cli/openclaw-runtime.cjs')
|
||||
|
||||
const EXTERNAL_PROVIDER_KINDS = new Set([
|
||||
'codex', 'hermes', 'openclaw', 'workbuddy', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen',
|
||||
'codex', 'hermes', 'openclaw', 'workbuddy', 'pi', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen',
|
||||
'opencodereview',
|
||||
])
|
||||
|
||||
@@ -10,6 +10,7 @@ const OFFICIAL_PROVIDER_BASE_URLS = Object.freeze({
|
||||
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',
|
||||
@@ -67,6 +68,16 @@ function providerOptionsFor(kind, generic, context = {}, status = {}) {
|
||||
},
|
||||
}
|
||||
}
|
||||
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: {
|
||||
|
||||
@@ -17,6 +17,7 @@ const OFFICIAL_PROVIDERS = Object.freeze({
|
||||
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' },
|
||||
|
||||
@@ -147,7 +147,7 @@ function classifiedCategory(error, code, status) {
|
||||
if (/INCOMPAT|UNSUPPORTED|PROTOCOL_UNAVAILABLE|REVIEW_ONLY|VERSION_MISMATCH/.test(code)) {
|
||||
return 'compatibility'
|
||||
}
|
||||
if (/SPAWN|EXITED|PROCESS|PROTOCOL|OUTCOME|OUTPUT_LIMIT|EMPTY_RESPONSE/.test(code)) {
|
||||
if (/SPAWN|EXITED|PROCESS|PROTOCOL|OUTCOME|OUTPUT_LIMIT|EMPTY_RESPONSE|COLLABORATION_RECEIPT/.test(code)) {
|
||||
return 'protocol'
|
||||
}
|
||||
return 'execution'
|
||||
@@ -157,7 +157,7 @@ function retryability(category, error) {
|
||||
if (category === 'session') return 'refresh_session'
|
||||
if (category === 'authentication') return 'never'
|
||||
if (category === 'compatibility') return 'never'
|
||||
if (['rate_limit', 'network', 'provider'].includes(category)) return 'retry'
|
||||
if (['rate_limit', 'network', 'provider', 'timeout', 'protocol'].includes(category)) return 'retry'
|
||||
if (error?.failure?.retryable === true) return 'retry'
|
||||
return 'never'
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ const FINAL_STATUSES = new Set([
|
||||
const CAPSULE_EVENT_TYPES = new Set([
|
||||
'reasoning_summary', 'plan', 'tool_start', 'tool_update', 'tool_result_summary', 'warning',
|
||||
])
|
||||
const TRACE_PHASES = new Set([
|
||||
'proposal', 'challenge', 'coordination', 'work', 'synthesis', 'verification', 'commit',
|
||||
'discussion', 'synthesis-recovery', 'manual_retry',
|
||||
])
|
||||
const INCOMPLETE_TOOL_EVENT_TYPES = new Set(['tool_start', 'tool_update'])
|
||||
const FAILED_TOOL_STATUSES = new Set(['failed', 'stopped', 'timeout', 'interrupted'])
|
||||
const PUBLIC_ID = /^[A-Za-z0-9._:-]{1,120}$/
|
||||
@@ -248,6 +252,10 @@ function normalizeContextStats(input) {
|
||||
const value = String(input[field] || '')
|
||||
if (SHA256.test(value)) context[field] = value
|
||||
}
|
||||
const operationId = cleanId(input.operationId)
|
||||
if (operationId) context.operationId = operationId
|
||||
const snapshotHash = String(input.snapshotHash || '')
|
||||
if (SHA256.test(snapshotHash)) context.snapshotHash = snapshotHash
|
||||
if (input.sessionRotated === true) context.sessionRotated = true
|
||||
const contextPackId = normalizeContextPackId(input.contextPackId)
|
||||
if (contextPackId) context.contextPackId = contextPackId
|
||||
@@ -328,6 +336,11 @@ function normalizeCapsuleRound(value, runId, agentRunId, hasExplicitRound) {
|
||||
return inferred <= 100000 ? inferred : null
|
||||
}
|
||||
|
||||
function normalizeTracePhase(value) {
|
||||
const phase = cleanText(value, 40, { inline: true }).toLowerCase()
|
||||
return TRACE_PHASES.has(phase) ? phase : ''
|
||||
}
|
||||
|
||||
function normalizeTraceCapsule(input) {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) return null
|
||||
const runId = cleanId(input.runId)
|
||||
@@ -339,6 +352,8 @@ function normalizeTraceCapsule(input) {
|
||||
agentRunId,
|
||||
Object.prototype.hasOwnProperty.call(input, 'round'),
|
||||
)
|
||||
const phase = normalizeTracePhase(input.phase)
|
||||
const executionSequence = boundedNumber(input.executionSequence, 1, 100000)
|
||||
const events = (Array.isArray(input.events) ? input.events : [])
|
||||
.slice(0, MAX_CAPSULE_EVENTS)
|
||||
.map(normalizeCapsuleEvent)
|
||||
@@ -347,6 +362,8 @@ function normalizeTraceCapsule(input) {
|
||||
runId,
|
||||
agentRunId,
|
||||
...(round != null ? { round } : {}),
|
||||
...(phase ? { phase } : {}),
|
||||
...(executionSequence != null ? { executionSequence } : {}),
|
||||
status: cleanStatus(input.status, 'completed'),
|
||||
summary: cleanText(input.summary, 1200),
|
||||
events,
|
||||
@@ -363,6 +380,7 @@ function traceCapsuleFromAgentRun(input, options = {}) {
|
||||
const kind = cleanId(input.kind || input.agentKind)
|
||||
if (!runId || !agentRunId || !kind) return null
|
||||
const round = boundedNumber(input.round, 0, 100000)
|
||||
const phase = normalizeTracePhase(options.phase || input.phase)
|
||||
const status = cleanStatus(options.status || input.status, 'interrupted')
|
||||
const capsuleEvents = (Array.isArray(input.events) ? input.events : [])
|
||||
.filter(item => CAPSULE_EVENT_TYPES.has(String(item?.type || '').toLowerCase()))
|
||||
@@ -387,6 +405,10 @@ function traceCapsuleFromAgentRun(input, options = {}) {
|
||||
runId,
|
||||
agentRunId,
|
||||
round,
|
||||
...(phase ? { phase } : {}),
|
||||
...(Number.isInteger(options.executionSequence)
|
||||
? { executionSequence: options.executionSequence }
|
||||
: {}),
|
||||
status,
|
||||
summary: narrative?.summary || input.summary || '',
|
||||
events: capsuleEvents,
|
||||
|
||||
@@ -298,10 +298,15 @@ class RunHarness {
|
||||
status: run.status,
|
||||
title: 'agent',
|
||||
})
|
||||
const phase = context && typeof context === 'object' ? context.phase : ''
|
||||
run.context = normalizeContextStats(context)
|
||||
const capsule = traceCapsuleFromAgentRun(run, {
|
||||
runId: this.runId,
|
||||
status: run.status,
|
||||
phase,
|
||||
executionSequence: context && Number.isInteger(context.executionSequence)
|
||||
? context.executionSequence
|
||||
: undefined,
|
||||
context: run.context,
|
||||
})
|
||||
return { event, capsule }
|
||||
|
||||
@@ -3,8 +3,10 @@ const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const { hasValidStoredRecordShape } = require('./run-ledger-records.cjs')
|
||||
const { atomicWritePrivateFile } = require('../security/private-file.cjs')
|
||||
|
||||
const JOURNAL_VERSION = 1
|
||||
const DEFAULT_COMPACTION_BYTES = 4 * 1024 * 1024
|
||||
const JOURNAL_ID = /^[A-Za-z0-9._:-]{1,160}$/
|
||||
const MAX_CHANGE_ITEMS = 1024
|
||||
const CHANGE_FIELDS = new Set(['replace', 'upserts', 'removedRunIds'])
|
||||
@@ -26,7 +28,7 @@ const CONTEXT_FIELDS = new Set([
|
||||
'includedCount', 'omittedCount', 'charCount', 'sessionRotated', 'externalRunRef',
|
||||
'contextMode', 'promptChars', 'contextPackId', 'contextPackState', 'deliveryRecordIds',
|
||||
'sessionProvenance', 'outcomeRefs', 'sourceCount', 'sourceHash', 'promptBytes',
|
||||
'promptHash', 'wirePayloadBytes', 'wirePayloadHash',
|
||||
'promptHash', 'wirePayloadBytes', 'wirePayloadHash', 'operationId', 'snapshotHash',
|
||||
'connector', 'connectorEventState',
|
||||
])
|
||||
|
||||
@@ -89,6 +91,9 @@ class RunJournal {
|
||||
? options.storagePath.trim()
|
||||
: ''
|
||||
if (!this.storagePath) throw new Error('RUN_JOURNAL_STORAGE_REQUIRED')
|
||||
this.compactionBytes = Number.isFinite(options.compactionBytes)
|
||||
? Math.max(1024, Math.floor(options.compactionBytes))
|
||||
: DEFAULT_COMPACTION_BYTES
|
||||
this.sequence = 0
|
||||
this.loadError = null
|
||||
this.hasBaseline = false
|
||||
@@ -231,6 +236,56 @@ class RunJournal {
|
||||
this.commit(transactionId, timestamp)
|
||||
}
|
||||
|
||||
compact(runs, timestamp) {
|
||||
this.assertLoaded()
|
||||
const change = {
|
||||
replace: true,
|
||||
upserts: clone(runs),
|
||||
removedRunIds: [],
|
||||
}
|
||||
if (!validChange(change)) throw new Error('RUN_JOURNAL_CHANGE_INVALID')
|
||||
const transactionId = `txn-${timestamp}-${crypto.randomBytes(8).toString('hex')}`
|
||||
const entries = [
|
||||
{
|
||||
version: JOURNAL_VERSION,
|
||||
sequence: 1,
|
||||
transactionId,
|
||||
timestamp,
|
||||
phase: 'prepare',
|
||||
change,
|
||||
},
|
||||
{
|
||||
version: JOURNAL_VERSION,
|
||||
sequence: 2,
|
||||
transactionId,
|
||||
timestamp,
|
||||
phase: 'commit',
|
||||
},
|
||||
]
|
||||
atomicWritePrivateFile(
|
||||
this.storagePath,
|
||||
`${entries.map(entry => JSON.stringify(entry)).join('\n')}\n`,
|
||||
)
|
||||
this.sequence = 2
|
||||
this.runs.clear()
|
||||
this.pending.clear()
|
||||
this.hasBaseline = false
|
||||
this.apply(change)
|
||||
}
|
||||
|
||||
compactIfNeeded(runs, timestamp) {
|
||||
this.assertLoaded()
|
||||
try {
|
||||
if (!fs.existsSync(this.storagePath) || fs.statSync(this.storagePath).size < this.compactionBytes) {
|
||||
return false
|
||||
}
|
||||
this.compact(runs, timestamp)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
recover() {
|
||||
this.assertLoaded()
|
||||
return this.hasBaseline ? clone([...this.runs.values()]) : null
|
||||
|
||||
@@ -42,7 +42,7 @@ const CONTEXT_FIELDS = new Set([
|
||||
'includedCount', 'omittedCount', 'charCount', 'sessionRotated', 'externalRunRef',
|
||||
'contextMode', 'promptChars', 'contextPackId', 'contextPackState', 'deliveryRecordIds',
|
||||
'sessionProvenance', 'outcomeRefs', 'sourceCount', 'sourceHash', 'promptBytes',
|
||||
'promptHash', 'wirePayloadBytes', 'wirePayloadHash',
|
||||
'promptHash', 'wirePayloadBytes', 'wirePayloadHash', 'operationId', 'snapshotHash',
|
||||
'connector', 'connectorEventState',
|
||||
])
|
||||
const CONTEXT_PACK_STATES = new Set(['captured', 'legacy-unavailable'])
|
||||
@@ -62,7 +62,7 @@ const CONTINUATION_FIELDS = new Set([
|
||||
])
|
||||
const CONTINUATION_INVOCATION_FIELDS = ['phase', 'slotId', 'operationId', 'snapshotHash']
|
||||
const CONTINUATION_V4_PHASES = new Set([
|
||||
'proposal', 'challenge', 'work', 'synthesis', 'verification',
|
||||
'proposal', 'discussion', 'challenge', 'work', 'synthesis', 'verification',
|
||||
])
|
||||
const CONTINUATION_GATE_TYPES = new Set(['permission', 'budget', 'decision', 'retry', 'input'])
|
||||
const CONTINUATION_RESUME_KINDS = new Set([
|
||||
@@ -75,8 +75,9 @@ const ACTIVE_CONTINUATION_STATES = new Set(['pending', 'ready', 'resuming'])
|
||||
const ORCHESTRATION_FIELDS = new Set([
|
||||
'version', 'workflow', 'currentKind', 'pendingKinds', 'activeKinds',
|
||||
'successfulKinds', 'agreementKinds', 'attachmentRecipients',
|
||||
'totalSuccesses', 'terminalFailureOccurred', 'collaboration', 'template', 'taskGraph',
|
||||
'phase', 'batchId', 'round', 'currentKinds', 'snapshotHash', 'snapshot', 'plan',
|
||||
'totalSuccesses', 'terminalFailureOccurred', 'executionSequence',
|
||||
'collaboration', 'template', 'taskGraph',
|
||||
'phase', 'discussionStyle', 'batchId', 'round', 'currentKinds', 'snapshotHash', 'snapshot', 'plan',
|
||||
'slots', 'deliveryWatermarks', 'deliveryState', 'commitState', 'challengeBindings', 'synthesisBinding',
|
||||
'synthesisRecovery', 'convergence', 'coordinationPlan', 'workReceipts', 'candidateCommit',
|
||||
])
|
||||
@@ -239,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',
|
||||
'connector', 'connectorEventState',
|
||||
'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 : []
|
||||
@@ -489,8 +490,9 @@ function normalizeOrchestration(input) {
|
||||
const agreementKinds = normalizeKinds(input.agreementKinds)
|
||||
const attachmentRecipients = normalizeKinds(input.attachmentRecipients)
|
||||
const totalSuccesses = boundedNumber(input.totalSuccesses, 0, 1000000)
|
||||
const executionSequence = boundedNumber(input.executionSequence, 0, 1000000)
|
||||
const v4OnlyFields = [
|
||||
'phase', 'batchId', 'round', 'currentKinds', 'snapshotHash', 'snapshot', 'plan',
|
||||
'phase', 'discussionStyle', 'batchId', 'round', 'currentKinds', 'snapshotHash', 'snapshot', 'plan',
|
||||
'slots', 'deliveryWatermarks', 'deliveryState', 'commitState', 'challengeBindings',
|
||||
'synthesisBinding', 'synthesisRecovery', 'convergence', 'candidateCommit',
|
||||
]
|
||||
@@ -498,6 +500,10 @@ function normalizeOrchestration(input) {
|
||||
|| (version === 1 && hasOwn(input, 'collaboration'))
|
||||
|| (version === 2 && workflow !== 'auto')
|
||||
|| (version < 3 && (hasOwn(input, 'template') || hasOwn(input, 'taskGraph')))
|
||||
|| (hasOwn(input, 'executionSequence')
|
||||
&& (!Number.isInteger(input.executionSequence)
|
||||
|| input.executionSequence < 0
|
||||
|| input.executionSequence > 1000000))
|
||||
|| (version < 4 && v4OnlyFields.some(field => hasOwn(input, field)))
|
||||
|| (version === 3 && (workflow !== 'auto' || input.template !== 'task-graph'))) {
|
||||
return undefined
|
||||
@@ -529,6 +535,7 @@ function normalizeOrchestration(input) {
|
||||
attachmentRecipients,
|
||||
totalSuccesses,
|
||||
terminalFailureOccurred: input.terminalFailureOccurred === true,
|
||||
...(hasOwn(input, 'executionSequence') ? { executionSequence } : {}),
|
||||
...(version >= 2 ? { collaboration } : {}),
|
||||
...(version === 3 ? { template: 'task-graph', taskGraph } : {}),
|
||||
}
|
||||
@@ -694,6 +701,11 @@ function hasValidStoredRecordShape(input) {
|
||||
&& (typeof agentRun.context[field] !== 'string'
|
||||
|| !SHA256.test(agentRun.context[field]))) return false
|
||||
}
|
||||
if (hasOwn(agentRun.context, 'operationId')
|
||||
&& cleanId(agentRun.context.operationId) !== agentRun.context.operationId) return false
|
||||
if (hasOwn(agentRun.context, 'snapshotHash')
|
||||
&& (typeof agentRun.context.snapshotHash !== 'string'
|
||||
|| !SHA256.test(agentRun.context.snapshotHash))) return false
|
||||
if (hasOwn(agentRun.context, 'contextPackId')
|
||||
&& normalizeContextPackId(agentRun.context.contextPackId)
|
||||
!== agentRun.context.contextPackId) return false
|
||||
|
||||
@@ -165,7 +165,10 @@ class RunLedger {
|
||||
))
|
||||
this.loadError = null
|
||||
this.snapshotError = null
|
||||
this.journal = new RunJournal({ storagePath: this.journalPath })
|
||||
this.journal = new RunJournal({
|
||||
storagePath: this.journalPath,
|
||||
compactionBytes: options.journalCompactionBytes,
|
||||
})
|
||||
this.runs = this.load()
|
||||
}
|
||||
|
||||
@@ -373,6 +376,7 @@ class RunLedger {
|
||||
}
|
||||
this.runs = nextRuns
|
||||
this.snapshotError = null
|
||||
this.journal.compactIfNeeded(this.journalBaseline(), timestamp)
|
||||
}
|
||||
|
||||
checkpoint(record) {
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
const DEFAULT_TASK_CONCURRENCY = 2
|
||||
const DEFAULT_WORKSPACE_CONCURRENCY = 2
|
||||
const DEFAULT_GLOBAL_CONCURRENCY = 4
|
||||
const DEFAULT_TASK_CONCURRENCY = null
|
||||
const DEFAULT_WORKSPACE_CONCURRENCY = null
|
||||
const DEFAULT_GLOBAL_CONCURRENCY = null
|
||||
const schedulerPauseTokens = new WeakMap()
|
||||
const schedulerLeaseBindings = new WeakMap()
|
||||
|
||||
function positiveLimit(value, fallback) {
|
||||
if (value == null) return fallback
|
||||
const number = Number(value)
|
||||
if (!Number.isFinite(number) || number < 1) return fallback
|
||||
return Math.min(1024, Math.floor(number))
|
||||
}
|
||||
|
||||
function belowLimit(active, limit) {
|
||||
return limit === null || active < limit
|
||||
}
|
||||
|
||||
function schedulerError(code) {
|
||||
return Object.assign(new Error(code), { code })
|
||||
}
|
||||
@@ -64,9 +69,9 @@ class RunScheduler {
|
||||
|
||||
canGrant(entry) {
|
||||
return taskPauseAllows(this, entry)
|
||||
&& this.activeGlobal < this.limits.global
|
||||
&& this.resourceCount(this.activeTasks, entry.taskId) < this.limits.task
|
||||
&& this.resourceCount(this.activeWorkspaces, entry.workspaceKey) < this.limits.workspace
|
||||
&& 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)
|
||||
}
|
||||
|
||||
changeCount(collection, key, change) {
|
||||
|
||||
@@ -172,6 +172,7 @@ function registerDesktopIpc(options) {
|
||||
getAttachmentStore,
|
||||
getAgentConnectors,
|
||||
getCloudAgentRuntime,
|
||||
getCloudAgentBridges,
|
||||
getKnowledgeConnectors,
|
||||
getMainWindow,
|
||||
getOutcomeStore,
|
||||
@@ -362,6 +363,44 @@ function registerDesktopIpc(options) {
|
||||
if (!LOCAL_RUN_IDENTIFIER.test(normalizedRunId)) throw new Error('CLOUD_AGENT_RUN_INVALID')
|
||||
return runtime.cancel(normalizedRunId)
|
||||
})
|
||||
registerTrustedHandle('local-cloud-agent-bridge:list', (...args) => {
|
||||
if (args.length) throw new Error('CLOUD_AGENT_BRIDGE_REQUEST_INVALID')
|
||||
const bridges = getCloudAgentBridges?.()
|
||||
if (!bridges) throw new Error('CLOUD_AGENT_BRIDGE_RUNTIME_UNAVAILABLE')
|
||||
return bridges.list()
|
||||
})
|
||||
registerTrustedHandle('local-cloud-agent-bridge:connect', async (...args) => {
|
||||
if (args.length !== 1 || !args[0] || typeof args[0] !== 'object'
|
||||
|| Array.isArray(args[0])
|
||||
|| Reflect.ownKeys(args[0]).some(key => !['address', 'label'].includes(key))
|
||||
|| typeof args[0].address !== 'string'
|
||||
|| (args[0].label !== undefined && typeof args[0].label !== 'string')) {
|
||||
throw new Error('CLOUD_AGENT_BRIDGE_REQUEST_INVALID')
|
||||
}
|
||||
const bridges = getCloudAgentBridges?.()
|
||||
if (!bridges) throw new Error('CLOUD_AGENT_BRIDGE_RUNTIME_UNAVAILABLE')
|
||||
const result = await bridges.connect(args[0])
|
||||
await refreshLocalAgentState()
|
||||
return result
|
||||
})
|
||||
registerTrustedHandle('local-cloud-agent-bridge:refresh', async (...args) => {
|
||||
if (args.length) throw new Error('CLOUD_AGENT_BRIDGE_REQUEST_INVALID')
|
||||
const bridges = getCloudAgentBridges?.()
|
||||
if (!bridges) throw new Error('CLOUD_AGENT_BRIDGE_RUNTIME_UNAVAILABLE')
|
||||
const result = await bridges.refresh()
|
||||
await refreshLocalAgentState()
|
||||
return result
|
||||
})
|
||||
registerTrustedHandle('local-cloud-agent-bridge:delete', async (...args) => {
|
||||
if (args.length !== 1 || typeof args[0] !== 'string') {
|
||||
throw new Error('CLOUD_AGENT_BRIDGE_REQUEST_INVALID')
|
||||
}
|
||||
const bridges = getCloudAgentBridges?.()
|
||||
if (!bridges) throw new Error('CLOUD_AGENT_BRIDGE_RUNTIME_UNAVAILABLE')
|
||||
const result = bridges.remove(args[0])
|
||||
await refreshLocalAgentState()
|
||||
return result
|
||||
})
|
||||
registerTrustedHandle('local-outcome:record-adoption', (...args) => {
|
||||
if (args.length !== 1) throw new Error('LOCAL_ADOPTION_REQUEST_INVALID')
|
||||
const outcomeStore = getOutcomeStore?.()
|
||||
@@ -385,6 +424,11 @@ function registerDesktopIpc(options) {
|
||||
if (customAgentStore.has(selectedKind)) {
|
||||
return { supported: false, skills: [], total: 0, limit: 0 }
|
||||
}
|
||||
const cloudSkills = getCloudAgentBridges?.()?.skillsForInstance?.(selectedKind)
|
||||
if (cloudSkills !== null && cloudSkills !== undefined) {
|
||||
const skills = cloudSkills.map(skill => ({ ...skill, targetKind: selectedKind }))
|
||||
return { supported: true, skills, total: skills.length, limit: 64 }
|
||||
}
|
||||
return installer.skills(selectedKind)
|
||||
})
|
||||
registerTrustedHandle('local-skill-trust:list', () => {
|
||||
@@ -615,6 +659,7 @@ 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))
|
||||
|
||||
@@ -40,9 +40,31 @@ class LocalWorkspaceAgentCatalog {
|
||||
this.emitChanged = options.emitChanged
|
||||
this.snapshot = options.snapshot
|
||||
this.now = options.now
|
||||
this.refreshTask = null
|
||||
this.refreshAgain = false
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
if (this.refreshTask) {
|
||||
this.refreshAgain = true
|
||||
return this.refreshTask
|
||||
}
|
||||
const task = (async () => {
|
||||
let snapshot
|
||||
do {
|
||||
this.refreshAgain = false
|
||||
snapshot = await this.refreshOnce()
|
||||
} while (this.refreshAgain)
|
||||
return snapshot
|
||||
})()
|
||||
const pending = task.finally(() => {
|
||||
if (this.refreshTask === pending) this.refreshTask = null
|
||||
})
|
||||
this.refreshTask = pending
|
||||
return pending
|
||||
}
|
||||
|
||||
async refreshOnce() {
|
||||
const runtimeAtRefreshStart = new Map(Object.entries(this.state().agentRuntime))
|
||||
const detected = await this.detectAgents()
|
||||
const nativeStates = await Promise.all(detected.map((agent) => {
|
||||
@@ -94,14 +116,18 @@ class LocalWorkspaceAgentCatalog {
|
||||
const installed = true
|
||||
const versionIdentified = agentVersionIdentified(agent)
|
||||
const compatible = agentCompatible(agent)
|
||||
const nativeConfigurationUnknown = !runtimeMissing
|
||||
&& nativeState === 'unknown'
|
||||
&& native?.source !== 'native-runtime-unavailable'
|
||||
const configured = sharedProviderReady
|
||||
|| nativeState === 'ready'
|
||||
|| native?.source === 'native-auth-status'
|
||||
|| native?.source === 'native-runtime-unavailable'
|
||||
|| verifiedReady
|
||||
|| runtimeMissing
|
||||
|| nativeConfigurationUnknown
|
||||
const authenticated = !runtimeMissing && nativeState !== 'missing' && (
|
||||
sharedProviderReady || nativeState === 'ready' || verifiedReady
|
||||
sharedProviderReady || nativeState === 'ready' || verifiedReady || nativeConfigurationUnknown
|
||||
)
|
||||
const runtimePrerequisitesReady = native?.source !== 'native-runtime-unavailable'
|
||||
const invocable = installed && versionIdentified && compatible && configured
|
||||
@@ -121,6 +147,7 @@ class LocalWorkspaceAgentCatalog {
|
||||
else if (nativeState === 'missing') availabilitySource = native.source || 'none'
|
||||
else if (nativeReadySource) availabilitySource = nativeReadySource
|
||||
else if (verifiedReady) availabilitySource = 'verified-run'
|
||||
else if (nativeConfigurationUnknown) availabilitySource = 'local-cli'
|
||||
return {
|
||||
...agent,
|
||||
installed,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const { createHash, randomUUID } = require('node:crypto')
|
||||
const {
|
||||
agentRuntimeCapabilities,
|
||||
isCodeReviewAgentKind,
|
||||
requireTerminalAgentResult,
|
||||
} = require('../agents/agent-runtime-contract.cjs')
|
||||
@@ -68,29 +69,35 @@ function mergeOutcomeRefs(sources, options = {}) {
|
||||
return normalizeOutcomeRefs(merged)
|
||||
}
|
||||
|
||||
function canUseNativeMediaFallback(error) {
|
||||
const code = String(error?.code || error?.message || '')
|
||||
return /^(?:MEDIA_GENERATION_(?:PROVIDER_UNAVAILABLE|MODEL_UNAVAILABLE|NETWORK_FAILED|INVALID_RESPONSE|FAILED|DOWNLOAD_FAILED)|MEDIA_GENERATION_(?:HTTP|DOWNLOAD_HTTP)_\d+)$/.test(code)
|
||||
}
|
||||
|
||||
function agentReturnedMediaProviderFailure(text) {
|
||||
const normalized = String(text || '').replace(/\s+/g, ' ').trim()
|
||||
if (!normalized) return false
|
||||
return /configured providers do not offer the required media model/i.test(normalized)
|
||||
|| /use a provider credential with access to (?:that|the) image, audio, or video model/i.test(normalized)
|
||||
|| /provider.+(?:does not|do not).+(?:offer|support).+(?:media|image|audio|video).+model/i.test(normalized)
|
||||
}
|
||||
const OPERATION_ID = /^[A-Za-z0-9._:-]{1,120}$/
|
||||
const SHA256 = /^[a-f0-9]{64}$/
|
||||
const V4_PHASES = new Set(['proposal', 'challenge', 'work', 'synthesis', 'verification'])
|
||||
const V4_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]*?)\]\]\s*$/i
|
||||
const V4_RECEIPT_BLOCK = /\s*\[\[MELDWORK_COLLABORATION(?:_JSON)?\]\]([\s\S]*?)\[\[\/MELDWORK_COLLABORATION(?:_JSON)?\]\]\s*$/i
|
||||
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 = cleanText(value, limit)
|
||||
@@ -107,13 +114,18 @@ function boundedReceiptList(value) {
|
||||
return result
|
||||
}
|
||||
|
||||
function parseV4CollaborationReceipt(result, required = false, expectedPhase = '') {
|
||||
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
|
||||
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()
|
||||
if (suffix && !isV4InternalProtocolCommentary(suffix)) {
|
||||
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
|
||||
}
|
||||
const encoded = String(block[1] || '').trim()
|
||||
if (!encoded || Buffer.byteLength(encoded) > V4_RECEIPT_MAX_BYTES) {
|
||||
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
|
||||
@@ -128,11 +140,28 @@ function parseV4CollaborationReceipt(result, required = false, expectedPhase = '
|
||||
if (error?.message === 'LOCAL_RUN_COLLABORATION_RECEIPT_MISMATCH') throw error
|
||||
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
|
||||
}
|
||||
text = rawText.slice(0, rawText.length - block[0].length)
|
||||
text = rawText.slice(0, markerStart)
|
||||
} else if (/\[\[MELDWORK_COLLABORATION/i.test(rawText)) {
|
||||
throw new Error('LOCAL_RUN_COLLABORATION_RECEIPT_INVALID')
|
||||
}
|
||||
text = text.replace(LEGACY_CONSENSUS_MARKER, '').replace(/\n{3,}/g, '\n\n').trim()
|
||||
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')
|
||||
@@ -212,7 +241,15 @@ function parseV4CollaborationReceipt(result, required = false, expectedPhase = '
|
||||
} : {}),
|
||||
...(phase === 'challenge' ? {
|
||||
...(Array.isArray(receipt.proposedAssignments)
|
||||
? { proposedAssignments: 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 } : {}),
|
||||
@@ -227,6 +264,39 @@ function parseV4CollaborationReceipt(result, required = false, expectedPhase = '
|
||||
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 attachInvocationFailure(error, input) {
|
||||
if (!error || (typeof error !== 'object' && typeof error !== 'function')) return error
|
||||
const existing = error.invocationFailure && typeof error.invocationFailure === 'object'
|
||||
@@ -246,7 +316,8 @@ function attachInvocationFailure(error, input) {
|
||||
}
|
||||
|
||||
function isolatedInvocationContext(context) {
|
||||
if (!['isolated', 'frozen'].includes(context?.sessionPolicy)) return null
|
||||
if (!['isolated', 'frozen'].includes(context?.sessionPolicy)
|
||||
&& !(context?.v4 === true && typeof context.promptOverride === 'string')) return null
|
||||
const promptOverride = typeof context.promptOverride === 'string'
|
||||
? context.promptOverride
|
||||
: ''
|
||||
@@ -259,7 +330,7 @@ function isolatedInvocationContext(context) {
|
||||
return {
|
||||
promptOverride,
|
||||
contextPackId,
|
||||
policy: context.sessionPolicy,
|
||||
policy: ['isolated', 'frozen'].includes(context?.sessionPolicy) ? context.sessionPolicy : '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,7 +480,9 @@ class LocalWorkspaceAgentInvocation {
|
||||
this.detectedAgents = options.detectedAgents
|
||||
this.activeRuns = options.activeRuns
|
||||
this.runAgentTimeoutMs = options.runAgentTimeoutMs
|
||||
this.runAgentToolTimeoutMs = options.runAgentToolTimeoutMs
|
||||
this.runAbortGraceMs = options.runAbortGraceMs
|
||||
this.defaultYolo = options.defaultYolo !== false
|
||||
this.captureAgentOutputs = options.captureAgentOutputs
|
||||
this.captureArtifactOutputs = options.captureArtifactOutputs
|
||||
this.captureAgentOutcomeDescriptors = options.captureAgentOutcomeDescriptors
|
||||
@@ -444,7 +517,7 @@ class LocalWorkspaceAgentInvocation {
|
||||
this.completeHumanGateContinuation = options.completeHumanGateContinuation
|
||||
this.connectorRuntime = options.connectorRuntime || null
|
||||
this.attachmentSupport = options.attachmentSupport || (() => ({}))
|
||||
this.generateMedia = typeof options.generateMedia === 'function' ? options.generateMedia : null
|
||||
this.activeAgentSnapshots = new Map()
|
||||
}
|
||||
|
||||
commitSessionState(mutator) {
|
||||
@@ -511,7 +584,24 @@ class LocalWorkspaceAgentInvocation {
|
||||
async invokeLeased(
|
||||
group, kind, mode, signal, threadRootId = '', context = {}, invocation = {},
|
||||
) {
|
||||
const agent = this.detectedAgents().find(item => item.kind === kind && item.available)
|
||||
const detected = this.detectedAgents()
|
||||
const activeRun = this.activeRuns.get(group.id)
|
||||
const candidate = detected.find(item => item.kind === kind)
|
||||
const snapshotKey = `${group.id}:${activeRun?.runId || 'active'}:${kind}`
|
||||
if (candidate) this.activeAgentSnapshots.set(snapshotKey, { ...candidate })
|
||||
const snapshot = this.activeAgentSnapshots.get(snapshotKey)
|
||||
const agent = detected.find(item => item.kind === kind && item.available)
|
||||
|| (activeRun?.targetKinds?.includes(kind)
|
||||
&& candidate
|
||||
&& candidate.installed !== false
|
||||
&& candidate.compatibilityState !== 'incompatible'
|
||||
&& candidate.credentialState !== 'missing'
|
||||
&& candidate.availabilitySource !== 'runtime-auth-failure'
|
||||
&& candidate.availabilitySource !== 'none'
|
||||
? candidate
|
||||
: activeRun?.targetKinds?.includes(kind) && !candidate && snapshot
|
||||
? snapshot
|
||||
: null)
|
||||
if (!agent) throw new Error('LOCAL_AGENT_UNAVAILABLE')
|
||||
const reviewOnly = isCodeReviewAgentKind(kind)
|
||||
const invocationContext = isolatedInvocationContext(context)
|
||||
@@ -524,9 +614,12 @@ class LocalWorkspaceAgentInvocation {
|
||||
: (context.permissionMode === 'workspace-write' ? 'workspace-write' : 'read-only')
|
||||
const writerKind = cleanText(context.singleWriterKind || context.writerKind, 80)
|
||||
const writerAllowed = !writerKind || writerKind === kind
|
||||
const supportsWorkspaceWrite = agentRuntimeCapabilities(kind, { agent })
|
||||
.permissionModes.includes('workspace-write')
|
||||
const allowWrite = group.allowWrite === true
|
||||
&& requestedPermission === 'workspace-write'
|
||||
&& writerAllowed
|
||||
&& supportsWorkspaceWrite
|
||||
&& !reviewOnly
|
||||
&& !isolated
|
||||
const skillInputTypes = new Set(['text'])
|
||||
@@ -546,10 +639,18 @@ class LocalWorkspaceAgentInvocation {
|
||||
})
|
||||
}
|
||||
const state = this.state()
|
||||
const activeRun = this.activeRuns.get(group.id)
|
||||
const taskId = cleanText(activeRun?.taskId || context.taskId || threadRootId, 120)
|
||||
const responseVersionRootId = cleanText(context.responseVersionRootId, 100)
|
||||
const round = mode === 'auto' ? (activeRun?.currentRound || 1) : 0
|
||||
const responseVersionRootId = cleanText(context.responseVersionRootId, 100)
|
||||
|| (mode === 'auto'
|
||||
? `response-${sha256(JSON.stringify([
|
||||
group.id,
|
||||
threadRootId,
|
||||
kind,
|
||||
round,
|
||||
cleanText(context.phase, 40) || 'response',
|
||||
])).slice(0, 48)}`
|
||||
: '')
|
||||
if (!isolated && !frozen) {
|
||||
const requiredContext = this.packedPromptContext(
|
||||
group.id, '', threadRootId, context.contextOptions || {},
|
||||
@@ -601,7 +702,12 @@ class LocalWorkspaceAgentInvocation {
|
||||
&& !resumedConnectorGate
|
||||
? context.resumedGate
|
||||
: null
|
||||
const sessionNeedsRotation = sessionRef && shouldRotateSession(sessionMeta)
|
||||
const preserveV4TaskSession = context.v4 === true
|
||||
&& group.conversationType !== 'direct'
|
||||
&& Boolean(taskId)
|
||||
const sessionNeedsRotation = sessionRef
|
||||
&& shouldRotateSession(sessionMeta)
|
||||
&& !preserveV4TaskSession
|
||||
if (resumedPermission) {
|
||||
const requestHash = sha256(canonicalJson(resumedPermission.request))
|
||||
const persistedBinding = {
|
||||
@@ -768,6 +874,8 @@ class LocalWorkspaceAgentInvocation {
|
||||
let watchdogReject = null
|
||||
let watchdogLastProgressAt = Date.now()
|
||||
let watchdogPaused = false
|
||||
let watchdogEstablished = false
|
||||
let watchdogToolActive = false
|
||||
let parentAbortHandler = null
|
||||
let parentAbortPromise = null
|
||||
let capturePromise = null
|
||||
@@ -781,6 +889,19 @@ class LocalWorkspaceAgentInvocation {
|
||||
let resumedPermissionUsed = false
|
||||
let resumedPermissionError = null
|
||||
const startedAt = Date.now()
|
||||
// Automatic discussions must make progress between rounds even when an
|
||||
// Agent keeps emitting heartbeat events without ever returning a result.
|
||||
// The watchdog is an inactivity timeout: streamed text, tool lifecycle,
|
||||
// and structured progress refresh it. Raw local process activity alone is
|
||||
// not proof of progress because some CLIs emit empty heartbeats forever.
|
||||
// Heartbeats keep a process alive long enough to prove that it started,
|
||||
// but cannot keep a turn alive forever without substantive output. Once a
|
||||
// real runtime event or structured progress arrives, the deadline is
|
||||
// lifted and the inactivity watchdog becomes event-driven.
|
||||
const hardDeadlineAt = mode === 'auto' && Number.isFinite(this.runAgentTimeoutMs)
|
||||
&& this.runAgentTimeoutMs > 0
|
||||
? startedAt + this.runAgentTimeoutMs
|
||||
: Number.POSITIVE_INFINITY
|
||||
if (signal) {
|
||||
parentAbortPromise = new Promise((_, reject) => {
|
||||
parentAbortHandler = () => {
|
||||
@@ -797,13 +918,20 @@ class LocalWorkspaceAgentInvocation {
|
||||
if (watchdogTimer || watchdogPaused || agentController.signal.aborted
|
||||
|| !Number.isFinite(this.runAgentTimeoutMs) || this.runAgentTimeoutMs <= 0) return
|
||||
const elapsedSinceProgress = Date.now() - watchdogLastProgressAt
|
||||
const remainingMs = Math.max(0, this.runAgentTimeoutMs - elapsedSinceProgress)
|
||||
const inactivityTimeoutMs = watchdogToolActive
|
||||
? this.runAgentToolTimeoutMs
|
||||
: this.runAgentTimeoutMs
|
||||
const inactivityRemainingMs = Math.max(0, inactivityTimeoutMs - elapsedSinceProgress)
|
||||
const hardRemainingMs = Math.max(
|
||||
0, (watchdogEstablished ? Number.POSITIVE_INFINITY : hardDeadlineAt) - Date.now(),
|
||||
)
|
||||
const remainingMs = Math.min(inactivityRemainingMs, hardRemainingMs)
|
||||
watchdogTimer = setTimeout(() => {
|
||||
watchdogTimer = null
|
||||
if (parentAbortObserved || signal?.aborted || watchdogPaused
|
||||
|| agentController.signal.aborted) return
|
||||
// This is an inactivity/stall timeout. Any progress signal refreshes the
|
||||
// window, so a slow tool or streamed response can run indefinitely.
|
||||
// This is an inactivity/stall timeout. Substantive progress refreshes
|
||||
// the window, while an unmatched tool lifecycle remains bounded.
|
||||
watchdogTimedOut = true
|
||||
watchdogError = new Error('LOCAL_AGENT_TIMEOUT')
|
||||
agentController.abort()
|
||||
@@ -824,6 +952,12 @@ class LocalWorkspaceAgentInvocation {
|
||||
armWatchdog()
|
||||
}
|
||||
}
|
||||
const establishWatchdog = () => {
|
||||
if (watchdogEstablished || agentController.signal.aborted) return
|
||||
watchdogEstablished = true
|
||||
clearTimeout(watchdogTimer)
|
||||
watchdogTimer = null
|
||||
}
|
||||
const resumeWatchdog = () => {
|
||||
watchdogPaused = false
|
||||
watchdogLastProgressAt = Date.now()
|
||||
@@ -848,6 +982,7 @@ class LocalWorkspaceAgentInvocation {
|
||||
const onProgress = (step) => {
|
||||
if (agentCallbacksClosed || agentController.signal.aborted
|
||||
|| !activeRun || this.activeRuns.get(group.id) !== activeRun) return
|
||||
establishWatchdog()
|
||||
noteWatchdogProgress()
|
||||
const next = [...attemptProgress]
|
||||
const progressId = typeof step?.id === 'string' && /^[A-Za-z0-9._:-]{1,100}$/.test(step.id)
|
||||
@@ -875,6 +1010,21 @@ class LocalWorkspaceAgentInvocation {
|
||||
|| !harness || !harnessRun || !rawEvent) return
|
||||
const event = harness.ingest(kind, round, rawEvent, harnessRun.agentRunId)
|
||||
if (!event) return
|
||||
// Once a connector has produced a real runtime event, the process is
|
||||
// demonstrably alive. Active tools use a longer shared inactivity window.
|
||||
establishWatchdog()
|
||||
const eventType = String(event.type || '').toLowerCase()
|
||||
const eventStatus = String(event.status || '').toLowerCase()
|
||||
if (['tool_start', 'tool_update'].includes(eventType)
|
||||
&& ['queued', 'running', 'waiting', 'in_progress', 'streaming'].includes(eventStatus)) {
|
||||
watchdogToolActive = true
|
||||
noteWatchdogProgress()
|
||||
} else if (eventType === 'tool_result_summary'
|
||||
|| (eventType === 'status' && ['completed', 'failed', 'stopped', 'timeout', 'interrupted']
|
||||
.includes(eventStatus))) {
|
||||
watchdogToolActive = false
|
||||
noteWatchdogProgress()
|
||||
}
|
||||
this.emitRunEvent(event)
|
||||
this.armAgentSilence(activeRun, kind, round, harnessRun.agentRunId)
|
||||
if (event.type !== 'answer_delta' || event.seq % 8 === 0) {
|
||||
@@ -965,8 +1115,6 @@ class LocalWorkspaceAgentInvocation {
|
||||
let outputBaseline = null
|
||||
let artifactOutputBaseline = null
|
||||
let stagedInputs = null
|
||||
let generatedMedia = null
|
||||
let nativeMediaFallback = false
|
||||
let result
|
||||
let operationStarted = false
|
||||
let sideEffectsStarted = false
|
||||
@@ -1013,6 +1161,16 @@ class LocalWorkspaceAgentInvocation {
|
||||
...deliveredContext.context,
|
||||
contextMode: promptMode,
|
||||
sessionRotated,
|
||||
...(context.phase ? { phase: context.phase } : {}),
|
||||
...(Number.isInteger(context.executionSequence)
|
||||
? { executionSequence: context.executionSequence }
|
||||
: {}),
|
||||
...(v4AgentSlotBinding
|
||||
? {
|
||||
operationId: v4AgentSlotBinding.operationId,
|
||||
snapshotHash: v4AgentSlotBinding.snapshotHash,
|
||||
}
|
||||
: {}),
|
||||
...runtimeContext,
|
||||
...deliveryContext,
|
||||
...connectorContext,
|
||||
@@ -1066,21 +1224,6 @@ class LocalWorkspaceAgentInvocation {
|
||||
/* artifact output capture is best effort */
|
||||
}
|
||||
}
|
||||
if (allowWrite && context.mediaRequest && this.generateMedia) {
|
||||
sideEffectsStarted = true
|
||||
try {
|
||||
generatedMedia = await abortableOperation(() => this.generateMedia({
|
||||
kind,
|
||||
request: context.mediaRequest,
|
||||
workdir: group.workdir,
|
||||
signal: agentController.signal,
|
||||
onEvent: emitRuntimeEvent,
|
||||
}), agentController.signal)
|
||||
} catch (error) {
|
||||
if (agentController.signal.aborted || !canUseNativeMediaFallback(error)) throw error
|
||||
nativeMediaFallback = true
|
||||
}
|
||||
}
|
||||
const nativeImageLimit = Math.max(
|
||||
0,
|
||||
Math.floor(Number(this.attachmentSupport(kind)?.image) || 0),
|
||||
@@ -1102,23 +1245,15 @@ class LocalWorkspaceAgentInvocation {
|
||||
const buildPrompt = (afterKind, contextPackage) => isolated || frozen
|
||||
? v4Prompt
|
||||
: [
|
||||
this.promptFor(
|
||||
group, kind, context.completionPolicy === 'typed' ? 'manual' : mode,
|
||||
threadRootId, context.skillHints || [],
|
||||
context.knowledgeBaseHints || [], afterKind, contextPackage, promptMode,
|
||||
activeRun?.unlimitedRounds === true,
|
||||
),
|
||||
context.v4 === true && v4Prompt
|
||||
? v4Prompt
|
||||
: this.promptFor(
|
||||
group, kind, context.completionPolicy === 'typed' ? 'manual' : mode,
|
||||
threadRootId, context.skillHints || [],
|
||||
context.knowledgeBaseHints || [], afterKind, contextPackage, promptMode,
|
||||
activeRun?.unlimitedRounds === true,
|
||||
),
|
||||
stagedAgentInputPrompt(stagedInputs),
|
||||
generatedMedia
|
||||
? `Meldwork generated and will attach ${generatedMedia.filename}. Confirm the delivered media briefly; do not claim a different file was created.`
|
||||
: '',
|
||||
nativeMediaFallback
|
||||
? [
|
||||
'Meldwork shared media generator was unavailable. Use your native media-generation tools or installed local skills to fulfill the current request.',
|
||||
'Write or copy every real generated media file into .meldwork-output/ so Meldwork can validate and attach it to the reply.',
|
||||
'Do not return only a prompt and do not claim success unless a real media file was created. If no native media tool is available, explain that accurately.',
|
||||
].join('\n')
|
||||
: '',
|
||||
runtimeInstruction ? `Harness recovery task:\n${runtimeInstruction}` : '',
|
||||
regenerationInstruction
|
||||
? `Fresh response instruction:\n${regenerationInstruction}`
|
||||
@@ -1262,6 +1397,9 @@ class LocalWorkspaceAgentInvocation {
|
||||
}
|
||||
const runOptions = {
|
||||
sessionRef,
|
||||
...(Number.isInteger(context.executionSequence)
|
||||
? { executionSequence: context.executionSequence }
|
||||
: {}),
|
||||
onSessionRef: (nextSessionRef, metadata = {}) => {
|
||||
if (agentCallbacksClosed || agentController.signal.aborted) return
|
||||
noteWatchdogProgress()
|
||||
@@ -1284,11 +1422,33 @@ class LocalWorkspaceAgentInvocation {
|
||||
signal: agentController.signal,
|
||||
sandbox: allowWrite ? 'workspace-write' : 'read-only',
|
||||
operationId,
|
||||
onActivity: noteWatchdogProgress,
|
||||
onActivity: () => {
|
||||
// A cloud request has no local process events while the remote CLI
|
||||
// is working. Its activity callback is therefore the first proof
|
||||
// that the run has started and must release the initial deadline.
|
||||
if (agent.cloud === true) establishWatchdog()
|
||||
if (agent.cloud === true || !watchdogToolActive) {
|
||||
noteWatchdogProgress()
|
||||
}
|
||||
},
|
||||
onProgress,
|
||||
onEvent: emitRuntimeEvent,
|
||||
onOutboundPayload: recordOutboundPayload,
|
||||
onPermissionRequest: (request, permissionContext = {}) => {
|
||||
// Permission prompts default to yolo for every local invocation.
|
||||
// Callers can opt into an interactive Gate explicitly with
|
||||
// `yolo: false`; sandbox and workspace authorization remain enforced
|
||||
// before this callback is reached.
|
||||
if ((context.yolo ?? this.defaultYolo) !== false) {
|
||||
const option = Array.isArray(request?.options)
|
||||
? request.options.find(candidate => candidate?.kind === 'allow_once')
|
||||
|| request.options.find(candidate => candidate?.kind === 'allow_always')
|
||||
|| request.options[0]
|
||||
: null
|
||||
if (option?.optionId) {
|
||||
return Promise.resolve({ status: 'approved', optionId: option.optionId })
|
||||
}
|
||||
}
|
||||
const pending = Promise.resolve().then(async () => {
|
||||
if (resumedPermission && !resumedPermissionUsed) {
|
||||
if (!matchesResumedPermission(request, resumedPermission)) {
|
||||
@@ -1336,12 +1496,16 @@ class LocalWorkspaceAgentInvocation {
|
||||
return pending
|
||||
},
|
||||
sessionTransport,
|
||||
...(kind === 'workbuddy' && Number.isInteger(context.maxInternalTurns)
|
||||
? { maxTurns: context.maxInternalTurns }
|
||||
: {}),
|
||||
attachments: isolated || frozen ? [] : (stagedInputs?.nativeImagePaths || []),
|
||||
// ACP runtimes are shared by the stable group/task session key. OpenClaw
|
||||
// is excluded because its gateway wrapper owns the process lifetime and
|
||||
// is intentionally torn down after each invocation.
|
||||
...(kind !== 'openclaw' ? { acpPersistenceKey: key } : {}),
|
||||
...(kind === 'hermes'
|
||||
? {
|
||||
acpPersistenceKey: key,
|
||||
hermesAcpAvailable: HERMES_WORKSPACE_ACP_ENABLED && agent.acpAvailable !== false,
|
||||
}
|
||||
? { hermesAcpAvailable: HERMES_WORKSPACE_ACP_ENABLED && agent.acpAvailable !== false }
|
||||
: {}),
|
||||
}
|
||||
const runCurrentSession = () => {
|
||||
@@ -1359,7 +1523,12 @@ class LocalWorkspaceAgentInvocation {
|
||||
...currentRunOptions,
|
||||
runId: activeRun.runId,
|
||||
agentRunId: harnessRun.agentRunId,
|
||||
onActivity: noteWatchdogProgress,
|
||||
onActivity: () => {
|
||||
if (agent.cloud === true) establishWatchdog()
|
||||
if (agent.cloud === true || !watchdogToolActive) {
|
||||
noteWatchdogProgress()
|
||||
}
|
||||
},
|
||||
onConnectorState: (nextContext) => {
|
||||
noteWatchdogProgress()
|
||||
connectorContext = nextContext
|
||||
@@ -1535,26 +1704,18 @@ class LocalWorkspaceAgentInvocation {
|
||||
this.markRuntimeCredential(kind, 'ready')
|
||||
|
||||
const collaborationReply = parseV4CollaborationReceipt(
|
||||
result,
|
||||
context.v4 === true,
|
||||
context.phase || '',
|
||||
result,
|
||||
context.v4 === true,
|
||||
context.phase || '',
|
||||
{
|
||||
allowMissingProposalReceipt: context.allowMissingProposalReceipt === true,
|
||||
allowMissingV4Receipt: context.allowMissingV4Receipt === true,
|
||||
},
|
||||
)
|
||||
const reply = mode === 'auto' && context.completionPolicy !== 'typed'
|
||||
? parseAutoReply(collaborationReply.text)
|
||||
: { text: collaborationReply.text, consensus: false }
|
||||
if (!reply.text) throw new Error('LOCAL_AGENT_EMPTY_RESPONSE')
|
||||
if (allowWrite && context.mediaRequest && this.generateMedia && nativeMediaFallback
|
||||
&& !generatedMedia && agentReturnedMediaProviderFailure(reply.text)) {
|
||||
sideEffectsStarted = true
|
||||
generatedMedia = await abortableOperation(() => this.generateMedia({
|
||||
kind,
|
||||
request: context.mediaRequest,
|
||||
workdir: group.workdir,
|
||||
signal: agentController.signal,
|
||||
onEvent: emitRuntimeEvent,
|
||||
}), agentController.signal)
|
||||
reply.text = `Meldwork generated and attached ${generatedMedia.filename}.`
|
||||
}
|
||||
const progress = attemptProgress.length ? attemptProgress : result.progress
|
||||
const toolCalls = cleanProgressSteps(progress).map(step => ({
|
||||
...step,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
const { terminalRunStatusForReason } = require('./local-workspace-inputs.cjs')
|
||||
const {
|
||||
normalizeFailure,
|
||||
} = require('../runs/failure-policy.cjs')
|
||||
|
||||
function authenticationFailureText(error) {
|
||||
return [
|
||||
error?.diagnostic,
|
||||
error?.message,
|
||||
error?.cause?.diagnostic,
|
||||
error?.cause?.message,
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
function unauthorizedFailure(error) {
|
||||
return normalizeFailure(error).category === 'authentication'
|
||||
}
|
||||
|
||||
function hardBudgetFailure(error) {
|
||||
return error?.code === 'LOCAL_BUDGET_EXHAUSTED'
|
||||
|| error?.message === 'LOCAL_BUDGET_EXHAUSTED'
|
||||
}
|
||||
|
||||
function circuitBreakerFailure(error) {
|
||||
return error?.code === 'LOCAL_RUN_CIRCUIT_BREAKER'
|
||||
|| error?.message === 'LOCAL_RUN_CIRCUIT_BREAKER'
|
||||
}
|
||||
|
||||
function circuitBreakerError() {
|
||||
return Object.assign(new Error('LOCAL_RUN_CIRCUIT_BREAKER'), {
|
||||
code: 'LOCAL_RUN_CIRCUIT_BREAKER',
|
||||
})
|
||||
}
|
||||
|
||||
function authenticationFailureStatus(error) {
|
||||
const status = normalizeFailure(error).httpStatus
|
||||
if ([401, 403].includes(status)) return status
|
||||
const message = authenticationFailureText(error)
|
||||
if (/(?:\bHTTP\s*)?\b403\b|forbidden/i.test(message)) return 403
|
||||
return 401
|
||||
}
|
||||
|
||||
function sanitizedAuthenticationError(error) {
|
||||
const statusCode = authenticationFailureStatus(error)
|
||||
const sanitized = Object.assign(
|
||||
new Error(`HTTP ${statusCode}; authentication failed; Agent retained`),
|
||||
{
|
||||
code: 'LOCAL_AGENT_AUTH_REQUIRED',
|
||||
statusCode,
|
||||
failure: Object.freeze({
|
||||
code: 'LOCAL_AGENT_AUTH_REQUIRED',
|
||||
category: 'authentication',
|
||||
retryable: false,
|
||||
}),
|
||||
},
|
||||
)
|
||||
if (error?.runTrace) {
|
||||
Object.defineProperty(sanitized, 'runTrace', {
|
||||
value: error.runTrace,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
function abortableDelay(delayMs, signal) {
|
||||
if (!delayMs) return Promise.resolve()
|
||||
if (signal?.aborted) return Promise.reject(new Error('LOCAL_AGENT_EXECUTION_STOPPED'))
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener('abort', abortHandler)
|
||||
resolve()
|
||||
}, delayMs)
|
||||
const abortHandler = () => {
|
||||
clearTimeout(timer)
|
||||
reject(new Error('LOCAL_AGENT_EXECUTION_STOPPED'))
|
||||
}
|
||||
signal?.addEventListener('abort', abortHandler, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
function stalePermissionResumeFailure(error, resumedGate) {
|
||||
const code = String(error?.code || error?.message || '')
|
||||
return resumedGate?.type === 'permission'
|
||||
&& ['LOCAL_RUN_PERMISSION_RESUME_UNAVAILABLE', 'LOCAL_AGENT_SESSION_INVALID'].includes(code)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
abortableDelay,
|
||||
authenticationFailureStatus,
|
||||
authenticationFailureText,
|
||||
circuitBreakerError,
|
||||
circuitBreakerFailure,
|
||||
hardBudgetFailure,
|
||||
sanitizedAuthenticationError,
|
||||
stalePermissionResumeFailure,
|
||||
terminalRunStatusForReason,
|
||||
unauthorizedFailure,
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
const { createHash } = require('node:crypto')
|
||||
|
||||
const {
|
||||
coordinationRecoveryKinds,
|
||||
createWorkReceipt,
|
||||
hashValue,
|
||||
} = require('../collaboration/orchestration-v4-records.cjs')
|
||||
const { canonicalJson } = require('../collaboration/context-pack-records.cjs')
|
||||
|
||||
const v4OutcomeMethods = {
|
||||
v4LatestReceipt(receiptRecords, phase, agentKind = '') {
|
||||
return [...receiptRecords].reverse().find(record => (
|
||||
record?.receipt?.phase === phase
|
||||
&& (!agentKind || record.receipt.agentKind === agentKind)
|
||||
)) || null
|
||||
},
|
||||
|
||||
v4WorkState(workReceipts, coordinationPlan) {
|
||||
if (!coordinationPlan) throw new Error('LOCAL_RUN_V4_COORDINATION_PLAN_INVALID')
|
||||
const completed = new Map()
|
||||
for (const assignment of coordinationPlan.assignments) {
|
||||
const exactReceipt = (Array.isArray(workReceipts) ? workReceipts : []).find(candidate => (
|
||||
candidate?.planHash === coordinationPlan.planHash
|
||||
&& candidate.taskId === assignment.taskId
|
||||
&& candidate.ownerKind === assignment.ownerKind
|
||||
))
|
||||
if (!exactReceipt) continue
|
||||
const record = { receipt: exactReceipt.collaborationReceipt }
|
||||
if (!this.v4ReceiptReferencesComplete(record)
|
||||
|| exactReceipt.artifacts.some((binding) => {
|
||||
const identity = this.v4ArtifactIdentity(binding.artifactId)
|
||||
return identity.contentHash !== binding.contentHash
|
||||
|| canonicalJson(identity.artifact.contentRef) !== canonicalJson(binding.contentRef)
|
||||
})) {
|
||||
throw new Error('LOCAL_RUN_V4_WORK_RECEIPT_INVALID')
|
||||
}
|
||||
completed.set(assignment.taskId, exactReceipt)
|
||||
}
|
||||
const pending = coordinationPlan.assignments.filter(assignment => !completed.has(assignment.taskId))
|
||||
const ready = pending.filter(assignment => (
|
||||
assignment.dependsOn.every(taskId => completed.has(taskId))
|
||||
))
|
||||
return { completed, pending, ready }
|
||||
},
|
||||
|
||||
v4ArtifactIdentity(artifactId) {
|
||||
if (!this.outcomeStore || !this.outcomeStore.contentBlobStore) {
|
||||
throw new Error('LOCAL_RUN_V4_CANDIDATE_INVALID')
|
||||
}
|
||||
try {
|
||||
const artifact = this.outcomeStore.getArtifact(artifactId)
|
||||
if (!artifact?.contentRef || artifact.contentHash !== artifact.contentRef.hash) {
|
||||
throw new Error('LOCAL_RUN_V4_CANDIDATE_INVALID')
|
||||
}
|
||||
const bytes = this.outcomeStore.contentBlobStore.read(artifact.contentRef)
|
||||
const contentHash = createHash('sha256').update(bytes).digest('hex')
|
||||
if (contentHash !== artifact.contentHash) {
|
||||
throw new Error('LOCAL_RUN_V4_CANDIDATE_INVALID')
|
||||
}
|
||||
return {
|
||||
artifact,
|
||||
contentHash,
|
||||
content: artifact.contentRef?.mediaType === 'text/plain'
|
||||
? bytes.toString('utf8')
|
||||
: '',
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.message === 'LOCAL_RUN_V4_CANDIDATE_INVALID') throw error
|
||||
throw new Error('LOCAL_RUN_V4_CANDIDATE_INVALID')
|
||||
}
|
||||
},
|
||||
|
||||
v4CandidateIdentity(record) {
|
||||
const artifactId = record?.receipt?.artifactIds?.[0]
|
||||
if (typeof artifactId !== 'string') throw new Error('LOCAL_RUN_V4_CANDIDATE_INVALID')
|
||||
const { contentHash } = this.v4ArtifactIdentity(artifactId)
|
||||
return { artifactId, contentHash }
|
||||
},
|
||||
|
||||
v4EvidenceRecord(evidenceId, errorCode = 'LOCAL_RUN_V4_REFERENCE_INVALID') {
|
||||
try {
|
||||
const evidence = this.outcomeStore.getEvidence(evidenceId)
|
||||
const concrete = evidence.refs.some(reference => (
|
||||
reference.type === 'artifact'
|
||||
|| reference.type === 'evidence'
|
||||
|| reference.type === 'blob'
|
||||
|| (reference.type === 'location' && Boolean(reference.contentHash))
|
||||
))
|
||||
if (!concrete) throw new Error(errorCode)
|
||||
return evidence
|
||||
} catch (error) {
|
||||
if (error?.message === errorCode) throw error
|
||||
throw new Error(errorCode)
|
||||
}
|
||||
},
|
||||
|
||||
v4EvidenceSupportsArtifact(evidenceId, artifactId, contentHash, seen = new Set()) {
|
||||
if (seen.has(evidenceId)) return false
|
||||
seen.add(evidenceId)
|
||||
const evidence = this.v4EvidenceRecord(
|
||||
evidenceId, 'LOCAL_RUN_V4_REVIEW_FINDING_INVALID',
|
||||
)
|
||||
const artifactBound = evidence.subject?.type === 'artifact'
|
||||
&& evidence.subject.artifactId === artifactId
|
||||
|| evidence.refs.some(reference => (
|
||||
reference.type === 'artifact' && reference.artifactId === artifactId
|
||||
))
|
||||
const contentBound = evidence.refs.some(reference => (
|
||||
reference.type === 'blob' && reference.contentHash === contentHash
|
||||
))
|
||||
if (artifactBound && contentBound) return true
|
||||
return evidence.refs.some(reference => (
|
||||
reference.type === 'evidence'
|
||||
&& this.v4EvidenceSupportsArtifact(
|
||||
reference.evidenceId, artifactId, contentHash, seen,
|
||||
)
|
||||
))
|
||||
},
|
||||
|
||||
v4ReceiptReferencesComplete(record, expectedArtifactId = '', expectedProducer = null) {
|
||||
const receipt = record?.receipt
|
||||
if (!receipt || !['completed', 'accepted'].includes(receipt.status)
|
||||
|| !receipt.artifactIds?.length || !receipt.evidenceIds?.length) return false
|
||||
const artifacts = new Map()
|
||||
for (const artifactId of receipt.artifactIds) {
|
||||
const identity = this.v4ArtifactIdentity(artifactId)
|
||||
artifacts.set(artifactId, identity)
|
||||
}
|
||||
const evidences = receipt.evidenceIds.map(evidenceId => this.v4EvidenceRecord(evidenceId))
|
||||
if (expectedProducer) {
|
||||
const runId = String(expectedProducer.runId || '')
|
||||
const agentRunId = String(expectedProducer.agentRunId || '')
|
||||
const agentKind = String(expectedProducer.agentKind || '')
|
||||
const operationId = String(expectedProducer.operationId || '')
|
||||
if (!runId || !agentRunId || !agentKind || !operationId
|
||||
|| receipt.agentKind !== agentKind || receipt.operationId !== operationId) return false
|
||||
const evidenceBindings = evidences.map((evidence) => {
|
||||
const recorder = evidence.recordedBy || {}
|
||||
const recorderBound = recorder.kind === 'system'
|
||||
? recorder.actorId === 'meldwork-main'
|
||||
: recorder.kind === 'agent'
|
||||
&& recorder.runId === runId
|
||||
&& recorder.agentRunId === agentRunId
|
||||
&& recorder.agentKind === agentKind
|
||||
if (!recorderBound) return null
|
||||
const boundArtifactIds = new Set([...artifacts.entries()]
|
||||
.filter(([artifactId, identity]) => {
|
||||
const artifactBound = evidence.subject?.type === 'artifact'
|
||||
&& evidence.subject.artifactId === artifactId
|
||||
|| evidence.refs.some(reference => (
|
||||
reference.type === 'artifact' && reference.artifactId === artifactId
|
||||
))
|
||||
const contentBound = evidence.refs.some(reference => (
|
||||
reference.type === 'blob'
|
||||
&& reference.contentHash === identity.contentHash
|
||||
&& canonicalJson(reference.contentRef)
|
||||
=== canonicalJson(identity.artifact.contentRef)
|
||||
))
|
||||
return artifactBound && contentBound
|
||||
})
|
||||
.map(([artifactId]) => artifactId))
|
||||
if (recorder.kind === 'system' && !boundArtifactIds.size) return null
|
||||
return boundArtifactIds
|
||||
})
|
||||
if (evidenceBindings.some(binding => !binding)) return false
|
||||
return [...artifacts.entries()].every(([artifactId, identity]) => {
|
||||
const artifact = identity.artifact
|
||||
return artifact.producedBy?.runId === runId
|
||||
&& artifact.producedBy?.agentRunId === agentRunId
|
||||
&& artifact.producedBy?.agentKind === agentKind
|
||||
&& evidenceBindings.some(binding => binding.has(artifactId))
|
||||
})
|
||||
}
|
||||
const artifactId = expectedArtifactId || receipt.artifactIds[0]
|
||||
const identity = artifacts.get(artifactId)
|
||||
if (!identity) return false
|
||||
return evidences.some(evidence => {
|
||||
const artifactBound = evidence.subject?.type === 'artifact'
|
||||
&& evidence.subject.artifactId === artifactId
|
||||
|| evidence.refs.some(reference => (
|
||||
reference.type === 'artifact' && reference.artifactId === artifactId
|
||||
))
|
||||
const contentBound = evidence.refs.some(reference => (
|
||||
reference.type === 'blob' && reference.contentHash === identity.contentHash
|
||||
))
|
||||
return artifactBound && contentBound
|
||||
})
|
||||
},
|
||||
|
||||
v4TrustedSynthesisResult({
|
||||
controller, slots, activeAttempt, synthesisRecovery, coordinationPlan, snapshotHash,
|
||||
}) {
|
||||
try {
|
||||
const rankedKinds = coordinationRecoveryKinds(coordinationPlan)
|
||||
if (!activeAttempt || !synthesisRecovery || !coordinationPlan?.planHash
|
||||
|| coordinationPlan.snapshotHash !== snapshotHash
|
||||
|| synthesisRecovery.originalWriterKind !== coordinationPlan.finalizerKind
|
||||
|| synthesisRecovery.activeWriterKind !== activeAttempt.writerKind
|
||||
|| canonicalJson(synthesisRecovery.rankedKinds) !== canonicalJson(rankedKinds)
|
||||
|| synthesisRecovery.rankingFingerprint !== hashValue({
|
||||
planHash: coordinationPlan.planHash,
|
||||
rankedKinds,
|
||||
})) return false
|
||||
const slot = slots.find(candidate => candidate.slotId === activeAttempt.slotId)
|
||||
if (!slot || slot.agentKind !== activeAttempt.writerKind
|
||||
|| slot.operationId !== activeAttempt.operationId
|
||||
|| slot.snapshotHash !== snapshotHash) return false
|
||||
const records = Array.isArray(slot.resultRefs?.workflowOutcomeRefs)
|
||||
? slot.resultRefs.workflowOutcomeRefs
|
||||
: []
|
||||
const record = [...records].reverse().find(candidate => {
|
||||
const receipt = candidate?.receipt
|
||||
return receipt?.phase === 'synthesis'
|
||||
&& receipt.agentKind === activeAttempt.writerKind
|
||||
&& receipt.slotId === activeAttempt.slotId
|
||||
&& receipt.operationId === activeAttempt.operationId
|
||||
&& receipt.snapshotHash === snapshotHash
|
||||
&& ['completed', 'accepted'].includes(receipt.status)
|
||||
})
|
||||
const receipt = record?.receipt
|
||||
if (!receipt?.artifactIds?.length || !receipt.evidenceIds?.length
|
||||
|| canonicalJson(receipt.artifactIds)
|
||||
!== canonicalJson(slot.resultRefs?.artifactIds || [])
|
||||
|| canonicalJson(receipt.evidenceIds)
|
||||
!== canonicalJson(slot.resultRefs?.evidenceIds || [])) return false
|
||||
return receipt.artifactIds.every((artifactId) => {
|
||||
const { artifact, contentHash } = this.v4ArtifactIdentity(artifactId)
|
||||
if (artifact.producedBy?.runId !== controller.runId
|
||||
|| artifact.producedBy?.agentKind !== activeAttempt.writerKind) return false
|
||||
return receipt.evidenceIds.some((evidenceId) => {
|
||||
const evidence = this.v4EvidenceRecord(evidenceId)
|
||||
return evidence.recordedBy?.kind === 'system'
|
||||
&& evidence.recordedBy.actorId === 'meldwork-main'
|
||||
&& evidence.subject?.type === 'artifact'
|
||||
&& evidence.subject.artifactId === artifactId
|
||||
&& evidence.refs.some(reference => (
|
||||
reference.type === 'artifact' && reference.artifactId === artifactId
|
||||
))
|
||||
&& evidence.refs.some(reference => (
|
||||
reference.type === 'blob'
|
||||
&& reference.contentHash === contentHash
|
||||
&& canonicalJson(reference.contentRef) === canonicalJson(artifact.contentRef)
|
||||
))
|
||||
})
|
||||
})
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
v4ExactWorkReceipt(receiptRecord, assignment, slot, snapshotRecord, snapshotHash,
|
||||
coordinationPlan) {
|
||||
const receipt = receiptRecord?.receipt
|
||||
if (!receipt || !assignment || !slot || !snapshotRecord?.bodyHash
|
||||
|| !snapshotRecord?.contentRef || !coordinationPlan?.planHash) {
|
||||
throw new Error('LOCAL_RUN_V4_WORK_RECEIPT_INVALID')
|
||||
}
|
||||
const artifacts = receipt.artifactIds.map((artifactId) => {
|
||||
const { artifact, contentHash } = this.v4ArtifactIdentity(artifactId)
|
||||
return {
|
||||
artifactId,
|
||||
contentHash,
|
||||
contentRef: artifact.contentRef,
|
||||
}
|
||||
})
|
||||
return createWorkReceipt({
|
||||
snapshotHash,
|
||||
snapshotBodyHash: snapshotRecord.bodyHash,
|
||||
snapshotContentRef: snapshotRecord.contentRef,
|
||||
planHash: coordinationPlan.planHash,
|
||||
taskId: assignment.taskId,
|
||||
ownerKind: assignment.ownerKind,
|
||||
slotId: slot.slotId,
|
||||
operationId: slot.operationId,
|
||||
collaborationReceipt: receipt,
|
||||
artifacts,
|
||||
})
|
||||
},
|
||||
|
||||
v4ReviewFindings(input, options = {}) {
|
||||
const record = input?.record
|
||||
const receipt = record?.receipt
|
||||
const reviewedArtifactId = String(input?.reviewedArtifactId || '')
|
||||
const runId = String(options.runId || '')
|
||||
const required = options.required === true || record?.verdict === 'contradict'
|
||||
if (!receipt || !['challenge', 'verification'].includes(receipt.phase)
|
||||
|| !['support', 'contradict'].includes(record?.verdict)
|
||||
|| !reviewedArtifactId || !runId) {
|
||||
throw new Error('LOCAL_RUN_V4_REVIEW_FINDING_INVALID')
|
||||
}
|
||||
const findingIds = Array.isArray(receipt.findingIds) ? receipt.findingIds : []
|
||||
if (required && findingIds.length === 0) {
|
||||
throw new Error('LOCAL_RUN_V4_REVIEW_FINDING_INVALID')
|
||||
}
|
||||
const { artifact, contentHash } = this.v4ArtifactIdentity(reviewedArtifactId)
|
||||
const receiptEvidenceIds = Array.isArray(receipt.evidenceIds) ? receipt.evidenceIds : []
|
||||
const receiptEvidenceSet = new Set(receiptEvidenceIds)
|
||||
return findingIds.map((findingId) => {
|
||||
let finding
|
||||
try { finding = this.outcomeStore.getReviewerFinding(findingId) } catch {
|
||||
throw new Error('LOCAL_RUN_V4_REVIEW_FINDING_INVALID')
|
||||
}
|
||||
if (finding.artifactId !== reviewedArtifactId
|
||||
|| finding.relation !== record.verdict
|
||||
|| finding.reviewer?.kind !== 'agent'
|
||||
|| finding.reviewer.runId !== runId
|
||||
|| finding.reviewer.agentKind !== receipt.agentKind
|
||||
|| !finding.reviewer.agentRunId
|
||||
|| !finding.evidenceIds.length) {
|
||||
throw new Error('LOCAL_RUN_V4_REVIEW_FINDING_INVALID')
|
||||
}
|
||||
for (const evidenceId of finding.evidenceIds) {
|
||||
if (!receiptEvidenceSet.has(evidenceId) && receiptEvidenceIds.length !== 64) {
|
||||
throw new Error('LOCAL_RUN_V4_REVIEW_FINDING_INVALID')
|
||||
}
|
||||
const evidence = this.v4EvidenceRecord(
|
||||
evidenceId, 'LOCAL_RUN_V4_REVIEW_FINDING_INVALID',
|
||||
)
|
||||
const recorder = evidence.recordedBy || {}
|
||||
const directArtifactBound = evidence.subject?.type === 'artifact'
|
||||
&& evidence.subject.artifactId === reviewedArtifactId
|
||||
&& evidence.refs.some(reference => (
|
||||
reference.type === 'artifact' && reference.artifactId === reviewedArtifactId
|
||||
))
|
||||
const exactContentBound = evidence.refs.some(reference => (
|
||||
reference.type === 'blob'
|
||||
&& reference.contentHash === contentHash
|
||||
&& canonicalJson(reference.contentRef) === canonicalJson(artifact.contentRef)
|
||||
))
|
||||
if (evidence.kind !== 'review'
|
||||
|| recorder.kind !== 'agent'
|
||||
|| recorder.runId !== finding.reviewer.runId
|
||||
|| recorder.agentRunId !== finding.reviewer.agentRunId
|
||||
|| recorder.agentKind !== finding.reviewer.agentKind
|
||||
|| !directArtifactBound || !exactContentBound) {
|
||||
throw new Error('LOCAL_RUN_V4_REVIEW_FINDING_INVALID')
|
||||
}
|
||||
}
|
||||
return finding
|
||||
})
|
||||
},
|
||||
|
||||
v4ReviewReceiptComplete(input, options = {}) {
|
||||
const status = input?.record?.receipt?.status
|
||||
if (!['completed', 'accepted'].includes(status)) return false
|
||||
return this.v4ReviewFindings(input, { ...options, required: true }).length > 0
|
||||
},
|
||||
|
||||
v4IssueId(record, findingId) {
|
||||
return `issue-${hashValue({
|
||||
phase: record.receipt.phase,
|
||||
reviewerFindingId: findingId,
|
||||
})}`
|
||||
},
|
||||
|
||||
v4IssueFromFinding(record, finding) {
|
||||
const { contentHash } = this.v4ArtifactIdentity(finding.artifactId)
|
||||
return {
|
||||
id: this.v4IssueId(record, finding.reviewerFindingId),
|
||||
artifactId: finding.artifactId,
|
||||
findingId: finding.reviewerFindingId,
|
||||
summary: finding.summary,
|
||||
semanticKey: hashValue({
|
||||
phase: record.receipt.phase,
|
||||
candidateContentHash: contentHash,
|
||||
relation: finding.relation,
|
||||
reviewerKind: finding.reviewer.agentKind,
|
||||
summary: finding.summary,
|
||||
}),
|
||||
}
|
||||
},
|
||||
|
||||
v4IssuesFromReviews(inputs, options = {}) {
|
||||
const issues = new Map()
|
||||
for (const input of inputs) {
|
||||
const record = input?.record
|
||||
const findings = this.v4ReviewFindings(input, options)
|
||||
if (record.verdict !== 'contradict') continue
|
||||
for (const finding of findings) {
|
||||
const issue = this.v4IssueFromFinding(record, finding)
|
||||
issues.set(issue.id, issue)
|
||||
}
|
||||
}
|
||||
return [...issues.values()].sort((left, right) => left.id.localeCompare(right.id))
|
||||
},
|
||||
|
||||
v4CarriedIssues(receiptRecords, openIssueIds, options = {}) {
|
||||
const requested = Array.isArray(openIssueIds) ? openIssueIds : []
|
||||
if (new Set(requested).size !== requested.length) {
|
||||
throw new Error('LOCAL_RUN_V4_ISSUE_INVALID')
|
||||
}
|
||||
if (requested.length === 0) return []
|
||||
const requestedSet = new Set(requested)
|
||||
const durableIssues = new Map()
|
||||
try {
|
||||
for (const record of receiptRecords) {
|
||||
if (!['challenge', 'verification'].includes(record?.receipt?.phase)
|
||||
|| record.verdict !== 'contradict') continue
|
||||
const findingIds = Array.isArray(record.receipt.findingIds)
|
||||
? record.receipt.findingIds : []
|
||||
if (findingIds.length === 0) throw new Error('LOCAL_RUN_V4_ISSUE_INVALID')
|
||||
for (const findingId of findingIds) {
|
||||
const issueId = this.v4IssueId(record, findingId)
|
||||
if (!requestedSet.has(issueId)) continue
|
||||
const finding = this.outcomeStore.getReviewerFinding(findingId)
|
||||
const scopedRecord = {
|
||||
...record,
|
||||
receipt: { ...record.receipt, findingIds: [findingId] },
|
||||
}
|
||||
if (!this.v4ReviewReceiptComplete({
|
||||
record: scopedRecord,
|
||||
reviewedArtifactId: finding.artifactId,
|
||||
}, options)) throw new Error('LOCAL_RUN_V4_ISSUE_INVALID')
|
||||
const issues = this.v4IssuesFromReviews([{
|
||||
record: scopedRecord,
|
||||
reviewedArtifactId: finding.artifactId,
|
||||
}], options)
|
||||
for (const issue of issues) durableIssues.set(issue.id, issue)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
throw new Error('LOCAL_RUN_V4_ISSUE_INVALID')
|
||||
}
|
||||
return requested.map((issueId) => {
|
||||
const issue = durableIssues.get(issueId)
|
||||
if (!issue) throw new Error('LOCAL_RUN_V4_ISSUE_INVALID')
|
||||
return issue
|
||||
}).sort((left, right) => left.id.localeCompare(right.id))
|
||||
},
|
||||
|
||||
v4ResolveIssues(openIssues, resolvedIssueIds) {
|
||||
const issues = Array.isArray(openIssues) ? openIssues : []
|
||||
const openIds = issues.map(issue => issue?.id)
|
||||
const resolved = Array.isArray(resolvedIssueIds) ? resolvedIssueIds : []
|
||||
if (openIds.some(issueId => typeof issueId !== 'string')
|
||||
|| new Set(openIds).size !== openIds.length
|
||||
|| resolved.some(issueId => typeof issueId !== 'string')
|
||||
|| new Set(resolved).size !== resolved.length
|
||||
|| resolved.some(issueId => !openIds.includes(issueId))) {
|
||||
throw new Error('LOCAL_RUN_V4_RESOLUTION_INVALID')
|
||||
}
|
||||
const closed = new Set(resolved)
|
||||
return issues.filter(issue => !closed.has(issue.id))
|
||||
},
|
||||
|
||||
v4ReceiptForOperation(receiptRecords, phase, agentKind, operationId = '') {
|
||||
return [...receiptRecords].reverse().find(record => (
|
||||
record?.receipt?.phase === phase
|
||||
&& record.receipt.agentKind === agentKind
|
||||
&& (!operationId || record.receipt.operationId === operationId)
|
||||
)) || null
|
||||
},
|
||||
|
||||
v4RoundState(receiptRecords, targetKinds, writerKind, verificationKinds, options = {}) {
|
||||
const slots = Array.isArray(options.slots) ? options.slots : []
|
||||
const challengeBindings = Array.isArray(options.challengeBindings)
|
||||
? options.challengeBindings : []
|
||||
const coordinationSupportReceiptIds = new Set(
|
||||
options.coordinationPlan?.supportReceiptIds || [],
|
||||
)
|
||||
const challengeInputs = targetKinds.map((kind) => {
|
||||
const binding = challengeBindings.find(item => item.reviewerKind === kind)
|
||||
if (binding) {
|
||||
const record = this.v4ReceiptForOperation(
|
||||
receiptRecords, 'challenge', kind, binding.reviewerOperationId,
|
||||
)
|
||||
return record && binding.artifactIds?.[0]
|
||||
? { record, reviewedArtifactId: binding.artifactIds[0] }
|
||||
: null
|
||||
}
|
||||
const record = [...receiptRecords].reverse().find(item => (
|
||||
item?.receipt?.phase === 'challenge'
|
||||
&& item.receipt.agentKind === kind
|
||||
&& coordinationSupportReceiptIds.has(item.receipt.receiptId)
|
||||
))
|
||||
const findingId = record?.receipt?.findingIds?.[0]
|
||||
let reviewedArtifactId = ''
|
||||
try {
|
||||
reviewedArtifactId = findingId
|
||||
? this.outcomeStore.getReviewerFinding(findingId).artifactId
|
||||
: ''
|
||||
} catch {}
|
||||
return record && reviewedArtifactId ? { record, reviewedArtifactId } : null
|
||||
}).filter(Boolean)
|
||||
const priorIssues = this.v4CarriedIssues(
|
||||
receiptRecords,
|
||||
options.previousConvergence?.openIssueIds || [],
|
||||
{ runId: options.runId },
|
||||
)
|
||||
const challengeIssues = this.v4IssuesFromReviews(challengeInputs, { runId: options.runId })
|
||||
const issueMap = new Map(priorIssues.map(issue => [issue.id, issue]))
|
||||
for (const issue of challengeIssues) {
|
||||
const carried = [...issueMap.values()].some(existing => (
|
||||
existing.semanticKey === issue.semanticKey
|
||||
))
|
||||
if (!carried) issueMap.set(issue.id, issue)
|
||||
}
|
||||
|
||||
const writerSlot = slots.find(slot => slot.agentKind === writerKind)
|
||||
const synthesis = writerSlot && this.v4ReceiptForOperation(
|
||||
receiptRecords, 'synthesis', writerKind, writerSlot.operationId,
|
||||
)
|
||||
if (!synthesis) throw new Error('LOCAL_RUN_V4_CANDIDATE_INVALID')
|
||||
const candidate = this.v4CandidateIdentity(synthesis)
|
||||
if (!this.v4ReceiptReferencesComplete(synthesis, candidate.artifactId)) {
|
||||
throw new Error('LOCAL_RUN_V4_CANDIDATE_INVALID')
|
||||
}
|
||||
let openIssues = this.v4ResolveIssues(
|
||||
[...issueMap.values()], synthesis.resolvedIssueIds || [],
|
||||
)
|
||||
const verificationInputs = verificationKinds.map((kind) => {
|
||||
const slot = slots.find(candidateSlot => candidateSlot.agentKind === kind)
|
||||
const record = slot && this.v4ReceiptForOperation(
|
||||
receiptRecords, 'verification', kind, slot.operationId,
|
||||
)
|
||||
return record ? { record, reviewedArtifactId: candidate.artifactId } : null
|
||||
}).filter(Boolean)
|
||||
if (options.includeVerification === true) {
|
||||
const verificationIssues = this.v4IssuesFromReviews(
|
||||
verificationInputs, { runId: options.runId },
|
||||
)
|
||||
const nextIssues = new Map(openIssues.map(issue => [issue.id, issue]))
|
||||
for (const issue of verificationIssues) {
|
||||
const carried = [...nextIssues.values()].some(existing => (
|
||||
existing.semanticKey === issue.semanticKey
|
||||
))
|
||||
if (!carried) nextIssues.set(issue.id, issue)
|
||||
}
|
||||
openIssues = [...nextIssues.values()].sort((left, right) => left.id.localeCompare(right.id))
|
||||
}
|
||||
return {
|
||||
candidate,
|
||||
synthesis,
|
||||
challengeInputs,
|
||||
verificationInputs,
|
||||
openIssues,
|
||||
}
|
||||
},
|
||||
|
||||
v4NextConvergence(previous, candidate, openIssueIds, round, writerKind) {
|
||||
const { artifact, contentHash } = this.v4ArtifactIdentity(candidate?.artifactId)
|
||||
const sortedIssueIds = [...new Set(openIssueIds || [])].sort()
|
||||
if (contentHash !== candidate?.contentHash || artifact.producedBy.agentKind !== writerKind
|
||||
|| !Number.isSafeInteger(round) || round < 2
|
||||
|| sortedIssueIds.length !== (openIssueIds || []).length
|
||||
|| sortedIssueIds.some(issueId => !/^issue-[a-f0-9]{64}$/.test(issueId))) {
|
||||
throw new Error('LOCAL_RUN_V4_CONVERGENCE_INVALID')
|
||||
}
|
||||
const stateKey = hashValue({ candidateContentHash: contentHash, openIssueIds: sortedIssueIds })
|
||||
const stateChanged = !previous || previous.stateKey !== stateKey
|
||||
let consecutiveStableRounds = 1
|
||||
if (!stateChanged && previous.lastCompletedRound === round - 1) {
|
||||
const previousArtifact = this.v4ArtifactIdentity(previous.candidateArtifactId).artifact
|
||||
if (previousArtifact.producedBy.agentKind === writerKind) {
|
||||
consecutiveStableRounds = previous.consecutiveStableRounds + 1
|
||||
}
|
||||
}
|
||||
return {
|
||||
candidateArtifactId: candidate.artifactId,
|
||||
candidateContentHash: contentHash,
|
||||
openIssueIds: sortedIssueIds,
|
||||
stateKey,
|
||||
lastCompletedRound: round,
|
||||
consecutiveStableRounds,
|
||||
stateEpoch: stateChanged ? (previous?.stateEpoch || 0) + 1 : previous.stateEpoch,
|
||||
acknowledgedGateEpoch: previous?.acknowledgedGateEpoch || 0,
|
||||
}
|
||||
},
|
||||
|
||||
v4ShouldOpenStableGate(convergence, unlimitedRounds) {
|
||||
return unlimitedRounds === true
|
||||
&& convergence?.consecutiveStableRounds >= 2
|
||||
&& convergence.acknowledgedGateEpoch !== convergence.stateEpoch
|
||||
},
|
||||
|
||||
v4Acceptance(receiptRecords, targetKinds, writerKind, verificationKinds, options = {}) {
|
||||
const state = this.v4RoundState(
|
||||
receiptRecords, targetKinds, writerKind, verificationKinds,
|
||||
{ ...options, includeVerification: true },
|
||||
)
|
||||
const proposals = targetKinds.map(kind => (
|
||||
this.v4LatestReceipt(receiptRecords, 'proposal', kind)
|
||||
)).filter(Boolean)
|
||||
const workState = options.coordinationPlan
|
||||
? this.v4WorkState(options.workReceipts, options.coordinationPlan)
|
||||
: null
|
||||
const referencesComplete = proposals.length === targetKinds.length
|
||||
&& proposals.every(record => this.v4ReceiptReferencesComplete(record))
|
||||
&& (!workState || workState.completed.size === options.coordinationPlan.assignments.length)
|
||||
&& this.v4ReceiptReferencesComplete(state.synthesis, state.candidate.artifactId)
|
||||
&& state.challengeInputs.length === targetKinds.length
|
||||
&& state.challengeInputs.every(input => this.v4ReviewReceiptComplete(
|
||||
input, { runId: options.runId },
|
||||
))
|
||||
&& state.verificationInputs.length === verificationKinds.length
|
||||
&& state.verificationInputs.every(input => this.v4ReviewReceiptComplete(
|
||||
input, { runId: options.runId },
|
||||
))
|
||||
const allSupport = state.verificationInputs.length === verificationKinds.length
|
||||
&& state.verificationInputs.every(input => input.record.verdict === 'support')
|
||||
const requiredSlotsHealthy = targetKinds.every(kind => {
|
||||
if (options.coordinationPlan && kind !== writerKind
|
||||
&& !verificationKinds.includes(kind)) {
|
||||
const assignments = options.coordinationPlan.assignments.filter(candidate => (
|
||||
candidate.ownerKind === kind
|
||||
))
|
||||
return assignments.length > 0 && assignments.every(assignment => (
|
||||
workState?.completed.has(assignment.taskId)
|
||||
))
|
||||
}
|
||||
const slot = options.slots?.find(candidate => candidate.agentKind === kind)
|
||||
const requiredPhase = kind === writerKind
|
||||
? 'synthesis'
|
||||
: (verificationKinds.includes(kind)
|
||||
? 'verification'
|
||||
: (options.coordinationPlan ? 'work' : 'challenge'))
|
||||
return slot?.status === 'completed' && slot.phase === requiredPhase
|
||||
})
|
||||
return {
|
||||
accepted: Boolean(referencesComplete && allSupport && requiredSlotsHealthy
|
||||
&& state.openIssues.length === 0),
|
||||
candidateHash: state.candidate.contentHash,
|
||||
candidateArtifactId: state.candidate.artifactId,
|
||||
openIssueIds: state.openIssues.map(issue => issue.id),
|
||||
unresolved: state.openIssues,
|
||||
synthesis: state.synthesis,
|
||||
verifications: state.verificationInputs.map(input => input.record),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = { v4OutcomeMethods }
|
||||
File diff suppressed because it is too large.
Load diff
@@ -219,6 +219,7 @@ function validateV4SnapshotBody({
|
||||
|
||||
function v4Prompt({
|
||||
group, kind, phase, snapshot, role = 'proposal', receipt = true, skillHints = null,
|
||||
naturalResponse = false,
|
||||
}) {
|
||||
const history = Array.isArray(snapshot?.history) && snapshot.history.length
|
||||
? snapshot.history.map(item => [
|
||||
@@ -227,14 +228,23 @@ function v4Prompt({
|
||||
].join('\n')).join('\n')
|
||||
: '(none)'
|
||||
const selectedAgentCount = Array.isArray(snapshot?.targetKinds) ? snapshot.targetKinds.length : 0
|
||||
const requiredVerifierCount = Math.min(2, Math.max(0, selectedAgentCount - 1))
|
||||
const phaseInstruction = {
|
||||
proposal: 'Develop an independent proposal. State at least one capability, intended work item, and Artifact you will deliver, plus an explicit dependencies array (which may be empty). Do not rely on another Agent output from this batch.',
|
||||
challenge: `Discuss the proposals as peers and negotiate one shared responsibility graph. Every selected Agent must own at least one substantive work package, and an Agent may own multiple dependent work packages. The graph must include one finalizerKind owned by an integrator. It must name exactly ${requiredVerifierCount} distinct verifierKinds from the selected Agents; verifierKinds must not contain finalizerKind. taskId values must be unique; dependsOn may reference only other taskId values and must remain acyclic. inputRefs are exact displayed frozen Source IDs; artifactIds are existing immutable Artifact IDs; future outputs flow only through dependsOn. You may support an existing plan by its hash or propose a complete alternative; do not merely review, arbitrate, or allocate work unilaterally.`,
|
||||
work: 'Execute the agreed responsibility assigned to you. Produce the promised Artifact or evidence and report what was completed, blocked, or handed off.',
|
||||
synthesis: 'Assemble the agreed work products into one bounded deliverable for the user.',
|
||||
verification: 'Verify the proposed deliverable independently and report only material defects or acceptance.',
|
||||
}[phase] || 'Complete the assigned collaboration phase.'
|
||||
const requiredVerifierCount = Math.max(0, selectedAgentCount - 1)
|
||||
const phaseInstruction = naturalResponse
|
||||
? {
|
||||
proposal: 'Work independently on the user task and offer your own analysis, recommendations, and next steps.',
|
||||
discussion: 'Continue from the available peer responses. Add useful evidence, challenge weak assumptions, resolve disagreements where possible, and advance the shared result.',
|
||||
challenge: 'Review the available peer context as a subject-matter peer. Explain what you agree with, what is missing, and the clearest correction or direction.',
|
||||
work: 'Complete the responsibility assigned to you. Report useful work, evidence, blockers, or handoffs in your own words.',
|
||||
synthesis: 'Combine the available work into the clearest useful answer for the user. Resolve contradictions where possible and state remaining uncertainty plainly.',
|
||||
verification: 'Independently check the proposed answer or deliverable. Report material defects, risks, or acceptance in your own words.',
|
||||
}[phase] || 'Address the current collaboration task directly.'
|
||||
: {
|
||||
proposal: 'Develop an independent proposal. State at least one capability, intended work item, and Artifact you will deliver, plus an explicit dependencies array (which may be empty). Do not rely on another Agent output from this batch.',
|
||||
challenge: `Discuss the proposals as peers and negotiate one shared responsibility graph. Every selected Agent must own at least one substantive work package, and an Agent may own multiple dependent work packages. The graph must include one finalizerKind owned by an integrator. It must name exactly ${requiredVerifierCount} distinct verifierKinds from the selected Agents; verifierKinds must not contain finalizerKind. taskId values must be unique; dependsOn may reference only other taskId values and must remain acyclic. inputRefs are exact displayed frozen Source IDs; artifactIds are existing immutable Artifact IDs; future outputs flow only through dependsOn. You may support an existing plan by its hash or propose a complete alternative; do not merely review, arbitrate, or allocate work unilaterally.`,
|
||||
work: 'Execute the agreed responsibility assigned to you. Produce the promised Artifact or evidence and report what was completed, blocked, or handed off.',
|
||||
synthesis: 'Assemble the agreed work products into one bounded deliverable for the user.',
|
||||
verification: 'Verify the proposed deliverable independently and report only material defects or acceptance.',
|
||||
}[phase] || 'Complete the assigned collaboration phase.'
|
||||
const receiptShape = phase === 'proposal'
|
||||
? 'Receipt JSON shape: [[MELDWORK_COLLABORATION:{"summary":"...","capabilities":["..."],"intendedWork":["..."],"deliverables":["..."],"dependencies":[]}]]'
|
||||
: phase === 'challenge'
|
||||
@@ -244,10 +254,11 @@ function v4Prompt({
|
||||
: ['challenge', 'verification'].includes(phase)
|
||||
? 'Receipt JSON shape: [[MELDWORK_COLLABORATION:{"verdict":"support|contradict","summary":"..."}]]'
|
||||
: 'Receipt JSON shape: [[MELDWORK_COLLABORATION:{"summary":"...","resolvedIssueIds":[]}]]'
|
||||
const receiptContract = receipt
|
||||
? [
|
||||
const receiptContract = receipt && !naturalResponse
|
||||
? [
|
||||
'Return the user-facing answer first, then append exactly one structured receipt marker.',
|
||||
receiptShape,
|
||||
'Do not mention the receipt, Meldwork protocol, plan files, plan mode, ExitPlanMode, or whether this is a code task in the user-facing answer. Append nothing after the marker.',
|
||||
'The receipt summary must be concise, factual, and contain no credentials, paths, commands, or private reasoning.',
|
||||
].join('\n')
|
||||
: ''
|
||||
@@ -257,6 +268,19 @@ function v4Prompt({
|
||||
const frozenSkillHints = Array.isArray(skillHints)
|
||||
? skillHints
|
||||
: (snapshot?.skillHintsByKind || []).find(item => item?.kind === kind)?.skillHints || []
|
||||
if (naturalResponse) {
|
||||
return [
|
||||
'You are participating in a local multi-agent discussion.',
|
||||
`User task:\n${cleanText(snapshot?.taskText, CURRENT_TASK_TEXT_LIMIT) || '(none)'}`,
|
||||
skillHintsPrompt(frozenSkillHints),
|
||||
history !== '(none)' ? `Relevant prior context:\n${history}` : '',
|
||||
phaseInstruction,
|
||||
'Engage with the other Agents as peers: discuss differences, challenge weak assumptions, build agreement where justified, and collaborate on the user\'s requested outcome.',
|
||||
'After concrete deliverables exist, review them for material gaps before accepting the result.',
|
||||
'Answer the user naturally in Markdown. Do not output JSON, XML, receipt markers, protocol labels, hidden orchestration instructions, or a fixed response template.',
|
||||
'Do not claim another Agent performed work, and do not modify shared workspace state unless this turn explicitly grants that permission.',
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
return [
|
||||
'MELDWORK_V4_FROZEN_SNAPSHOT_V1',
|
||||
`Phase: ${phase}`,
|
||||
@@ -803,6 +827,22 @@ function promptFor({
|
||||
knowledgeBaseHintsPrompt(knowledgeBaseHints),
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
if (group.conversationType === 'direct') {
|
||||
// A private 1:1 chat has no group framing, no multi-agent context, and no
|
||||
// "Current user task" label wrapping the query. The agent's native session
|
||||
// owns the conversation history, so only the operational contracts that the
|
||||
// agent cannot infer on its own (workspace/deliverable capture, response
|
||||
// language) and the raw user query are delivered. The query is the prompt
|
||||
// body, never nested under a system-prompt label.
|
||||
return [
|
||||
mediaDelivery,
|
||||
languageContract,
|
||||
unlimitedReviewText,
|
||||
skillHintsPrompt(skillHints),
|
||||
knowledgeBaseHintsPrompt(knowledgeBaseHints),
|
||||
packed.currentTaskText || '(none)',
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
return [
|
||||
`You are participating in the local "${group.name || 'Meldwork group'}" conversation as ${label}.`,
|
||||
`Group topic: ${cleanText(group.topic, 200) || '(not specified)'}`,
|
||||
|
||||
@@ -3,6 +3,7 @@ const AGENT_LABELS = {
|
||||
hermes: 'Hermes',
|
||||
openclaw: 'OpenClaw',
|
||||
workbuddy: 'WorkBuddy',
|
||||
pi: 'Pi Agent',
|
||||
kimi: 'Kimi',
|
||||
mimo: 'MiMo',
|
||||
claude: 'Claude',
|
||||
@@ -12,6 +13,7 @@ const AGENT_LABELS = {
|
||||
opencodereview: 'OpenCodeReview',
|
||||
}
|
||||
const CUSTOM_AGENT_KIND = /^custom-[a-f0-9]{16}$/
|
||||
const CLOUD_AGENT_KIND = /^cloud-[a-f0-9]{24}$/
|
||||
const MAX_MESSAGE_TEXT_CHARS = 20000
|
||||
const MAX_SYSTEM_PARAM_TEXT_CHARS = 1000
|
||||
const STABLE_USER_TURNS_PER_EDGE = 3
|
||||
@@ -22,7 +24,10 @@ const RECENT_TRANSCRIPT_TEXT_LIMIT = 9000
|
||||
const SESSION_KEY = /^[A-Za-z0-9._:-]{1,240}$/
|
||||
|
||||
function isSupportedAgentKind(kind) {
|
||||
return Object.hasOwn(AGENT_LABELS, kind) || CUSTOM_AGENT_KIND.test(String(kind || ''))
|
||||
const normalized = String(kind || '')
|
||||
return Object.hasOwn(AGENT_LABELS, normalized)
|
||||
|| CUSTOM_AGENT_KIND.test(normalized)
|
||||
|| CLOUD_AGENT_KIND.test(normalized)
|
||||
}
|
||||
|
||||
function defaultAgentLabel(kind) {
|
||||
@@ -44,6 +49,7 @@ function cleanInline(value, limit = 80) {
|
||||
|
||||
module.exports = {
|
||||
AGENT_LABELS,
|
||||
CLOUD_AGENT_KIND,
|
||||
CUSTOM_AGENT_KIND,
|
||||
MAX_MESSAGE_TEXT_CHARS,
|
||||
MAX_SYSTEM_PARAM_TEXT_CHARS,
|
||||
|
||||
@@ -48,10 +48,31 @@ class LocalWorkspaceRunLedger {
|
||||
try { this.runLedger?.deleteGroup?.(record.groupId) } catch {}
|
||||
continue
|
||||
}
|
||||
for (const agentRun of Array.isArray(record.agentRuns) ? record.agentRuns : []) {
|
||||
const agentRuns = Array.isArray(record.agentRuns) ? record.agentRuns : []
|
||||
const latestAttemptByKind = new Map()
|
||||
for (const attempt of Array.isArray(record.attemptHistory) ? record.attemptHistory : []) {
|
||||
const previous = latestAttemptByKind.get(attempt.agentKind)
|
||||
if (!previous || attempt.sequence > previous.sequence) {
|
||||
latestAttemptByKind.set(attempt.agentKind, attempt)
|
||||
}
|
||||
}
|
||||
const latestTerminalFailureByKind = new Map()
|
||||
for (const agentRun of agentRuns) {
|
||||
if (['failed', 'timeout'].includes(String(agentRun?.status || '').toLowerCase())) {
|
||||
latestTerminalFailureByKind.set(agentRun.kind, agentRun.agentRunId)
|
||||
}
|
||||
}
|
||||
for (const agentRun of agentRuns) {
|
||||
const status = String(agentRun?.status || '').toLowerCase()
|
||||
if (!RECOVERABLE_AGENT_STATUSES.has(status)
|
||||
|| !isSupportedAgentKind(agentRun?.kind)) continue
|
||||
if (['failed', 'timeout'].includes(status)) {
|
||||
const latestAttempt = latestAttemptByKind.get(agentRun.kind)
|
||||
if (latestAttempt?.finalOutcome === 'succeeded'
|
||||
|| latestTerminalFailureByKind.get(agentRun.kind) !== agentRun.agentRunId) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
const output = cleanText(agentRun.output, DEFAULT_MAX_OUTPUT_CHARS)
|
||||
const trace = traceCapsuleFromAgentRun({
|
||||
...agentRun,
|
||||
|
||||
@@ -19,7 +19,6 @@ const {
|
||||
terminalRunStatusForReason,
|
||||
} = require('./local-workspace-inputs.cjs')
|
||||
const { MAX_RUN_AGENT_ATTEMPTS } = require('../runs/failure-policy.cjs')
|
||||
const { mediaGenerationRequest } = require('../media/media-generation-request.cjs')
|
||||
const { createTaskGraph } = require('../collaboration/task-graph-records.cjs')
|
||||
const { assertLocalSkillExecution } = require('../skills/local-skill-contract.cjs')
|
||||
const {
|
||||
@@ -45,6 +44,8 @@ const {
|
||||
validateV4SnapshotBody,
|
||||
} = require('./local-workspace-context.cjs')
|
||||
|
||||
const DISCUSSION_STYLES = new Set(['sequential', 'agent-led'])
|
||||
|
||||
function hardBudgetFailure(error) {
|
||||
return error?.code === 'LOCAL_BUDGET_EXHAUSTED'
|
||||
|| error?.message === 'LOCAL_BUDGET_EXHAUSTED'
|
||||
@@ -254,7 +255,6 @@ class LocalWorkspaceMessageSubmission {
|
||||
]))
|
||||
return {
|
||||
text,
|
||||
mediaRequest: mediaGenerationRequest(text),
|
||||
attachments,
|
||||
skillHintsByKind,
|
||||
skillHints: targetKinds.flatMap(kind => publicSkillHintsByKind.get(kind) || []),
|
||||
@@ -337,15 +337,28 @@ class LocalWorkspaceMessageSubmission {
|
||||
if (regenerateMessageId && (mode !== 'manual' || targetKinds.length !== 1)) {
|
||||
throw new Error('LOCAL_MESSAGE_REGENERATION_INVALID')
|
||||
}
|
||||
const v4 = (mode === 'manual' || mode === 'auto')
|
||||
&& group.conversationType !== 'direct'
|
||||
&& targetKinds.length > 1
|
||||
&& !regenerateMessageId
|
||||
&& input.protocol !== 'legacy'
|
||||
&& this.v4Requested(input)
|
||||
const requestedDiscussionStyle = input.discussionStyle == null
|
||||
? ''
|
||||
: cleanInline(input.discussionStyle, 40)
|
||||
if (requestedDiscussionStyle && !DISCUSSION_STYLES.has(requestedDiscussionStyle)) {
|
||||
throw new Error('LOCAL_DISCUSSION_STYLE_INVALID')
|
||||
}
|
||||
if (requestedDiscussionStyle && (mode !== 'auto' || !v4)) {
|
||||
throw new Error('LOCAL_DISCUSSION_STYLE_UNSUPPORTED')
|
||||
}
|
||||
return {
|
||||
mode,
|
||||
targetKinds,
|
||||
v4: (mode === 'manual' || mode === 'auto')
|
||||
&& group.conversationType !== 'direct'
|
||||
&& targetKinds.length > 1
|
||||
&& !regenerateMessageId
|
||||
&& input.protocol !== 'legacy'
|
||||
&& this.v4Requested(input),
|
||||
v4,
|
||||
discussionStyle: mode === 'auto' && v4
|
||||
? (requestedDiscussionStyle || 'sequential')
|
||||
: '',
|
||||
unlimitedRounds,
|
||||
maxRounds,
|
||||
requestedThreadRootId,
|
||||
@@ -735,7 +748,7 @@ class LocalWorkspaceMessageSubmission {
|
||||
}
|
||||
|
||||
async commitV4ManualBarrier({ group, controller, targetKinds, slots, snapshotRecord,
|
||||
batchId, writerKind }) {
|
||||
batchId, writerKind, allowStoppedCommit = false }) {
|
||||
const successfulSlots = slots.filter(slot => ['completed', 'partial'].includes(slot.status))
|
||||
const committedKinds = successfulSlots
|
||||
.filter(slot => slot.commitStatus === 'committed')
|
||||
@@ -777,7 +790,7 @@ class LocalWorkspaceMessageSubmission {
|
||||
this.checkpointV4Manual(group, controller)
|
||||
|
||||
for (const slot of successfulSlots) {
|
||||
if (controller.signal.aborted) break
|
||||
if (controller.signal.aborted && !allowStoppedCommit) break
|
||||
const resultBody = this.loadV4ResultBody(slot)
|
||||
const workflowRecord = slot.resultRefs?.workflowOutcomeRefs?.find(item => (
|
||||
item?.receipt?.receiptId === slot.receiptId
|
||||
@@ -918,6 +931,7 @@ class LocalWorkspaceMessageSubmission {
|
||||
singleWriterKind: writerKind || '',
|
||||
parallelGraph: true,
|
||||
deferMessage: true,
|
||||
allowMissingProposalReceipt: true,
|
||||
operationId: slot.operationId,
|
||||
onLeaseAcquired: (leaseState) => {
|
||||
if (leaseAcquired) return
|
||||
@@ -1112,9 +1126,13 @@ class LocalWorkspaceMessageSubmission {
|
||||
if (!controller.signal.aborted && slot.status === 'completed') continue
|
||||
const error = item.error || item.invocation?.error || new Error('LOCAL_AGENT_UNKNOWN_FAILURE')
|
||||
failures.push({ ...item, error })
|
||||
if (!controller.signal.aborted) this.recordAgentFailure(
|
||||
group.id, item.kind, error, threadRootId,
|
||||
)
|
||||
if (!controller.signal.aborted) {
|
||||
this.recordAgentFailure(group.id, item.kind, error, threadRootId)
|
||||
} else if (controller.stopReason === 'user'
|
||||
&& slot.agentRunId
|
||||
&& error?.runTrace?.agentRunId === slot.agentRunId) {
|
||||
this.recordAgentInterruption(group.id, item.kind, error, threadRootId, 'stopped')
|
||||
}
|
||||
}
|
||||
controller.completedKinds = targetKinds.filter(kind => {
|
||||
const status = slots.find(slot => slot.agentKind === kind)?.status
|
||||
@@ -1188,7 +1206,7 @@ class LocalWorkspaceMessageSubmission {
|
||||
const successfulKinds = slots
|
||||
.filter(slot => ['completed', 'partial'].includes(slot.status))
|
||||
.map(slot => slot.agentKind)
|
||||
if (!controller.signal.aborted) {
|
||||
if (!controller.signal.aborted || controller.stopReason === 'user') {
|
||||
await this.commitV4ManualBarrier({
|
||||
group,
|
||||
controller,
|
||||
@@ -1197,6 +1215,7 @@ class LocalWorkspaceMessageSubmission {
|
||||
snapshotRecord: frozenSnapshot.record,
|
||||
batchId,
|
||||
writerKind,
|
||||
allowStoppedCommit: controller.stopReason === 'user',
|
||||
})
|
||||
}
|
||||
return {
|
||||
@@ -1422,6 +1441,7 @@ class LocalWorkspaceMessageSubmission {
|
||||
regenerateMessageId,
|
||||
routingDecision,
|
||||
taskGraph,
|
||||
discussionStyle,
|
||||
} = this.validateInput(group, input)
|
||||
const regeneration = this.resolveRegeneration(group, regenerateMessageId, targetKinds)
|
||||
const reservation = this.reserveRun(
|
||||
@@ -1433,6 +1453,7 @@ class LocalWorkspaceMessageSubmission {
|
||||
unlimitedRounds,
|
||||
)
|
||||
reservation.v4 = v4
|
||||
reservation.discussionStyle = discussionStyle
|
||||
try {
|
||||
if (regeneration) reservation.responseVersionRootId = regeneration.responseVersionRootId
|
||||
this.configureRunBudget(reservation, input.budget || {})
|
||||
@@ -1581,7 +1602,6 @@ class LocalWorkspaceMessageSubmission {
|
||||
let activeKinds = [...targetKinds]
|
||||
const pendingKinds = [...targetKinds]
|
||||
const replacementInstructions = new Map()
|
||||
const mediaOwnerKind = prepared.mediaRequest ? targetKinds[0] : ''
|
||||
while (pendingKinds.length) {
|
||||
const kind = pendingKinds.shift()
|
||||
if (!activeKinds.includes(kind)) continue
|
||||
@@ -1613,7 +1633,6 @@ class LocalWorkspaceMessageSubmission {
|
||||
attachmentSnapshots: prepared.attachments,
|
||||
sessionThreadRootId: requestedThreadRootId || threadRootId,
|
||||
runtimeInstruction: replacementInstructions.get(kind) || '',
|
||||
mediaRequest: kind === mediaOwnerKind ? prepared.mediaRequest : null,
|
||||
responseVersionRootId: regeneration?.responseVersionRootId || '',
|
||||
regenerationInstruction: regeneration ? this.regenerationInstruction() : '',
|
||||
contextOptions: regeneration
|
||||
@@ -1791,6 +1810,18 @@ class LocalWorkspaceMessageSubmission {
|
||||
group.id, 'auto', targetKinds, threadRootId, maxRounds, unlimitedRounds,
|
||||
)
|
||||
reservation.v4 = input.protocol !== 'legacy' && this.v4Requested(input)
|
||||
const requestedDiscussionStyle = input.discussionStyle == null
|
||||
? ''
|
||||
: cleanInline(input.discussionStyle, 40)
|
||||
if (requestedDiscussionStyle && !DISCUSSION_STYLES.has(requestedDiscussionStyle)) {
|
||||
this.releasePreparation(group.id, reservation)
|
||||
throw new Error('LOCAL_DISCUSSION_STYLE_INVALID')
|
||||
}
|
||||
if (requestedDiscussionStyle && !reservation.v4) {
|
||||
this.releasePreparation(group.id, reservation)
|
||||
throw new Error('LOCAL_DISCUSSION_STYLE_UNSUPPORTED')
|
||||
}
|
||||
reservation.discussionStyle = requestedDiscussionStyle || 'sequential'
|
||||
try {
|
||||
this.configureRunBudget(reservation, input.budget || {})
|
||||
const contextPack = this.createContextPack({
|
||||
|
||||
@@ -18,6 +18,17 @@ class LocalWorkspaceRunMessages {
|
||||
this.addMessage = options.addMessage
|
||||
}
|
||||
|
||||
responseVersionRootId(groupId, kind, threadRootId, trace = null) {
|
||||
const traceRound = Number(trace?.round)
|
||||
const round = Number.isInteger(traceRound) && traceRound >= 0 ? traceRound : 1
|
||||
const phase = cleanText(trace?.phase, 40)
|
||||
|| cleanText(trace?.context?.phase, 40)
|
||||
|| 'response'
|
||||
return `response-${createHash('sha256').update(JSON.stringify([
|
||||
String(groupId || ''), String(threadRootId || ''), String(kind || ''), round, phase,
|
||||
])).digest('hex').slice(0, 48)}`
|
||||
}
|
||||
|
||||
recordFailure(groupId, kind, error, threadRootId, reportedFailures = null) {
|
||||
const label = this.agentLabel(kind)
|
||||
const reason = cleanText(error?.message || error, MAX_SYSTEM_PARAM_TEXT_CHARS)
|
||||
@@ -29,6 +40,8 @@ class LocalWorkspaceRunMessages {
|
||||
this.checkpointRun(groupId, controller)
|
||||
}
|
||||
const streamedConclusion = this.streamedConclusion(groupId, agentRunId)
|
||||
const trace = error?.runTrace || null
|
||||
const responseVersionRootId = this.responseVersionRootId(groupId, kind, threadRootId, trace)
|
||||
const hardBudget = reason === 'LOCAL_BUDGET_EXHAUSTED'
|
||||
&& error?.decision?.action === 'terminal'
|
||||
const content = terminalMessageContent(
|
||||
@@ -60,7 +73,7 @@ class LocalWorkspaceRunMessages {
|
||||
kind,
|
||||
threadRootId,
|
||||
system,
|
||||
{ trace: error?.runTrace },
|
||||
{ trace, responseVersionRootId },
|
||||
)
|
||||
}
|
||||
return { label, reason }
|
||||
@@ -90,6 +103,7 @@ class LocalWorkspaceRunMessages {
|
||||
}
|
||||
const interrupted = status === 'interrupted'
|
||||
const streamedConclusion = this.streamedConclusion(groupId, trace.agentRunId)
|
||||
const responseVersionRootId = this.responseVersionRootId(groupId, kind, threadRootId, trace)
|
||||
this.addMessage(
|
||||
groupId,
|
||||
'system',
|
||||
@@ -100,7 +114,7 @@ class LocalWorkspaceRunMessages {
|
||||
key: interrupted ? 'system.agentInterrupted' : 'system.agentStopped',
|
||||
params: { agent: label },
|
||||
},
|
||||
{ trace },
|
||||
{ trace, responseVersionRootId },
|
||||
)
|
||||
return trace
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ const {
|
||||
const AUTO_CONSENSUS_MARKER = /\[\[MELDWORK_CONSENSUS:(agree|continue)\]\]/gi
|
||||
const AUTO_FINAL_CONSENSUS_MARKER = /(?:^|\r?\n)[ \t]*\[\[MELDWORK_CONSENSUS:(agree|continue)\]\][ \t]*$/i
|
||||
const DEFAULT_RUN_SILENCE_WARNING_MS = 20 * 1000
|
||||
const DEFAULT_RUN_AGENT_TIMEOUT_MS = 15 * 60 * 1000
|
||||
const DEFAULT_RUN_AGENT_TIMEOUT_MS = 3 * 60 * 1000
|
||||
const DEFAULT_RUN_AGENT_TOOL_TIMEOUT_MS = 5 * 60 * 1000
|
||||
const DEFAULT_RUN_ABORT_GRACE_MS = 2500
|
||||
const DEFAULT_AUTO_ROUNDS = 6
|
||||
const MAX_AUTO_ROUNDS = 10
|
||||
@@ -191,6 +192,7 @@ module.exports = {
|
||||
DEFAULT_AUTO_ROUNDS,
|
||||
DEFAULT_RUN_ABORT_GRACE_MS,
|
||||
DEFAULT_RUN_AGENT_TIMEOUT_MS,
|
||||
DEFAULT_RUN_AGENT_TOOL_TIMEOUT_MS,
|
||||
DEFAULT_RUN_SILENCE_WARNING_MS,
|
||||
HERMES_WORKSPACE_ACP_ENABLED,
|
||||
RECOVERABLE_AGENT_STATUSES,
|
||||
|
||||
@@ -19,7 +19,7 @@ const FAILED_AGENT_STATUSES = new Set(['failed', 'stopped', 'timeout'])
|
||||
const TERMINAL_PERSISTENCE_STATES = new Set(['pending', 'retrying', 'failed'])
|
||||
|
||||
const V4_PHASES = new Set([
|
||||
'prepare', 'dispatch', 'running', 'reconcile', 'proposal', 'challenge',
|
||||
'prepare', 'dispatch', 'running', 'reconcile', 'proposal', 'discussion', 'challenge',
|
||||
'coordination', 'work', 'synthesis', 'verification', 'commit', 'committed', 'completed',
|
||||
'human-gate', 'stopped', 'failed',
|
||||
])
|
||||
|
||||
@@ -50,6 +50,9 @@ const {
|
||||
normalizeSessionRef,
|
||||
terminalRunStatusForReason,
|
||||
} = require('./local-workspace-inputs.cjs')
|
||||
const {
|
||||
DEFAULT_RUN_AGENT_TOOL_TIMEOUT_MS,
|
||||
} = require('./local-workspace-runtime-contracts.cjs')
|
||||
const { MAX_RUN_AGENT_ATTEMPTS } = require('../runs/failure-policy.cjs')
|
||||
const {
|
||||
loadWorkspaceState,
|
||||
@@ -63,6 +66,47 @@ const TERMINAL_RUN_STATUSES = new Set([
|
||||
'completed', 'partial', 'failed', 'stopped', 'timeout', 'round-limit', 'interrupted',
|
||||
'budget-exhausted', 'circuit-breaker',
|
||||
])
|
||||
const V4_VISIBLE_MESSAGE_PHASES = ['proposal', 'discussion', 'challenge', 'work', 'verification']
|
||||
|
||||
function naturalV4MessageBindingMatches(message, record, phase) {
|
||||
if (!record.orchestration?.discussionStyle || !['proposal', 'discussion'].includes(phase)) {
|
||||
return true
|
||||
}
|
||||
const round = Number(message?.trace?.round) || 0
|
||||
const slot = record.orchestration.slots?.find(candidate => (
|
||||
candidate.agentKind === message.agentKind
|
||||
))
|
||||
if (round < 1 || !slot?.slotId) return false
|
||||
const operationPhase = phase === 'discussion' ? `discussion:${round}` : phase
|
||||
const operationId = `operation-${createHash('sha256').update(JSON.stringify([
|
||||
record.runId,
|
||||
record.taskId,
|
||||
message.agentKind,
|
||||
slot.slotId,
|
||||
operationPhase,
|
||||
round,
|
||||
])).digest('hex')}`
|
||||
return message.trace?.context?.operationId === operationId
|
||||
&& message.trace?.context?.snapshotHash === record.orchestration.snapshotHash
|
||||
}
|
||||
|
||||
function isCommittedV4PhaseMessage(message, record) {
|
||||
const agentRunId = String(message?.trace?.agentRunId || '')
|
||||
if (message?.role !== 'agent' || message?.groupId !== record.groupId
|
||||
|| message?.trace?.runId !== record.runId || !message.agentKind || !agentRunId) {
|
||||
return false
|
||||
}
|
||||
return V4_VISIBLE_MESSAGE_PHASES.some((phase) => {
|
||||
if (message.id !== `message-${hashValue({
|
||||
agentKind: message.agentKind,
|
||||
agentRunId,
|
||||
phase,
|
||||
runId: record.runId,
|
||||
taskId: record.taskId,
|
||||
})}`) return false
|
||||
return naturalV4MessageBindingMatches(message, record, phase)
|
||||
})
|
||||
}
|
||||
|
||||
class LocalWorkspace extends EventEmitter {
|
||||
constructor(options) {
|
||||
@@ -70,7 +114,6 @@ class LocalWorkspace extends EventEmitter {
|
||||
this.storagePath = options.storagePath
|
||||
this.detectAgentsFn = options.detectAgents
|
||||
this.runAgentFn = options.runAgent
|
||||
this.generateMediaFn = options.generateMedia || null
|
||||
this.resolveAttachmentsFn = options.resolveAttachments || (async (attachments) => {
|
||||
if (attachments?.length) throw new Error('LOCAL_ATTACHMENT_STORAGE_UNAVAILABLE')
|
||||
return []
|
||||
@@ -97,6 +140,10 @@ class LocalWorkspace extends EventEmitter {
|
||||
&& options.runAgentTimeoutMs > 0
|
||||
? options.runAgentTimeoutMs
|
||||
: DEFAULT_RUN_AGENT_TIMEOUT_MS
|
||||
this.runAgentToolTimeoutMs = Number.isFinite(options.runAgentToolTimeoutMs)
|
||||
&& options.runAgentToolTimeoutMs > 0
|
||||
? options.runAgentToolTimeoutMs
|
||||
: DEFAULT_RUN_AGENT_TOOL_TIMEOUT_MS
|
||||
this.runAbortGraceMs = Number.isFinite(options.runAbortGraceMs)
|
||||
&& options.runAbortGraceMs > 0
|
||||
? options.runAbortGraceMs
|
||||
@@ -224,7 +271,10 @@ class LocalWorkspace extends EventEmitter {
|
||||
detectedAgents: () => this.detectedAgents,
|
||||
activeRuns: this.activeRuns,
|
||||
runAgentTimeoutMs: this.runAgentTimeoutMs,
|
||||
runAgentToolTimeoutMs: this.runAgentToolTimeoutMs,
|
||||
runAbortGraceMs: this.runAbortGraceMs,
|
||||
defaultYolo: options.defaultYolo,
|
||||
naturalAgentResponses: options.naturalAgentResponses,
|
||||
captureAgentOutputs: (...args) => this.captureAgentOutputsFn(...args),
|
||||
captureArtifactOutputs: (...args) => this.captureArtifactOutputsFn(...args),
|
||||
captureAgentOutcomeDescriptors: (...args) => (
|
||||
@@ -259,7 +309,6 @@ class LocalWorkspace extends EventEmitter {
|
||||
completeHumanGateContinuation: (...args) => this.completeHumanGateContinuation(...args),
|
||||
connectorRuntime: options.connectorRuntime,
|
||||
attachmentSupport: (...args) => this.attachmentSupportFn(...args),
|
||||
generateMedia: (...args) => this.generateMediaFn?.(...args),
|
||||
})
|
||||
this.autoRunner = new LocalWorkspaceAutoRunner({
|
||||
state: () => this.state,
|
||||
@@ -268,6 +317,8 @@ class LocalWorkspace extends EventEmitter {
|
||||
validateSkillSelections: (...args) => this.validateSkillSelectionsFn(...args),
|
||||
validateKnowledgeBaseSelections: (...args) => this.validateKnowledgeBaseSelectionsFn(...args),
|
||||
invokeAgent: (...args) => this.invokeAgent(...args),
|
||||
defaultYolo: options.defaultYolo,
|
||||
naturalAgentResponses: options.naturalAgentResponses,
|
||||
resetAgentSession: (...args) => this.resetAgentSession(...args),
|
||||
refreshAgents: () => this.refreshAgents(),
|
||||
consumeAgentControl: (...args) => this.runCoordinator.consumeAgentControl(...args),
|
||||
@@ -276,6 +327,7 @@ class LocalWorkspace extends EventEmitter {
|
||||
recordAgentFailure: (...args) => this.recordAgentFailure(...args),
|
||||
recordAgentInterruption: (...args) => this.recordAgentInterruption(...args),
|
||||
addMessage: (...args) => this.addMessage(...args),
|
||||
removeAgent: (...args) => this.conversations.removeAgent(...args),
|
||||
commitV4AgentMessage: input => this.commitV4AgentMessage(input),
|
||||
emitChanged: () => this.emitChanged(),
|
||||
finishRun: (...args) => this.finishRun(...args),
|
||||
@@ -397,7 +449,9 @@ class LocalWorkspace extends EventEmitter {
|
||||
? [durable.orchestration.candidateCommit.messageId] : []),
|
||||
])
|
||||
const retainedMessages = this.state.messages.filter(message => (
|
||||
message.trace?.runId !== durable.runId || allowedMessageIds.has(message.id)
|
||||
message.trace?.runId !== durable.runId
|
||||
|| allowedMessageIds.has(message.id)
|
||||
|| isCommittedV4PhaseMessage(message, durable)
|
||||
))
|
||||
if (retainedMessages.length !== this.state.messages.length) {
|
||||
this.state.messages = retainedMessages
|
||||
@@ -443,7 +497,7 @@ class LocalWorkspace extends EventEmitter {
|
||||
const commitState = orchestration?.commitState
|
||||
const candidateCommit = orchestration?.candidateCommit
|
||||
const resumablePhase = [
|
||||
'proposal', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
|
||||
'proposal', 'discussion', 'challenge', 'coordination', 'work', 'synthesis', 'verification',
|
||||
].includes(orchestration?.phase)
|
||||
const resumableCommit = ['commit', 'committed', 'completed'].includes(orchestration?.phase)
|
||||
&& ['committing', 'committed'].includes(commitState?.status)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Desktop Tests
|
||||
|
||||
Desktop tests mirror the source domains under `desktop/src`. Keep a test next to
|
||||
the domain it verifies, and put reusable fixtures or process harnesses in
|
||||
`test/support` rather than copying them into individual suites.
|
||||
|
||||
The test runner discovers `*.test.cjs` recursively. Use a focused suite while
|
||||
iterating, then run the complete suite before delivery:
|
||||
|
||||
```sh
|
||||
npm run test:agents
|
||||
npm run test:runs
|
||||
npm run test:workspace
|
||||
npm test
|
||||
```
|
||||
|
||||
Suite selection is defined in `scripts/test-discovery.cjs`. Add a new domain to
|
||||
`TEST_SUITES` when it needs an independent, repeatable test command.
|
||||
@@ -62,6 +62,15 @@ test('keeps unidentified versions distinct from verified incompatibility', () =>
|
||||
})
|
||||
})
|
||||
|
||||
test('accepts the verified Pi Agent release', () => {
|
||||
assert.deepEqual(assessAgentVersion('pi', '0.84.2'), {
|
||||
resolvedVersion: '0.84.2',
|
||||
supportedVersionRange: '0.84.2',
|
||||
compatibilityState: 'compatible',
|
||||
incompatibilityReason: '',
|
||||
})
|
||||
})
|
||||
|
||||
test('accepts inclusive release ranges and rejects unvalidated versions', () => {
|
||||
for (const [kind, contract] of Object.entries(AGENT_COMPATIBILITY)) {
|
||||
const supported = contract.exactVersion
|
||||
@@ -78,6 +87,7 @@ test('accepts inclusive release ranges and rejects unvalidated versions', () =>
|
||||
['codex', '0.146.0-alpha.9.3'],
|
||||
['hermes', '0.19.1-beta.1'],
|
||||
['openclaw', '2026.7.1'],
|
||||
['pi', '0.2.0'],
|
||||
['gemini', '0.53.2'],
|
||||
['opencodereview', '1.8.7'],
|
||||
]) {
|
||||
@@ -92,9 +102,9 @@ test('accepts inclusive release ranges and rejects unvalidated versions', () =>
|
||||
|
||||
test('defines required capability probes for every supported connector', () => {
|
||||
assert.deepEqual(Object.keys(AGENT_COMPATIBILITY).sort(), [
|
||||
'claude', 'codex', 'gemini', 'hermes', 'kimi', 'mimo', 'openclaw',
|
||||
'claude', 'codex', 'gemini', 'hermes', 'kimi', 'mimo', 'openclaw', 'pi',
|
||||
'opencode', 'opencodereview', 'qwen', 'workbuddy',
|
||||
])
|
||||
].sort())
|
||||
for (const kind of Object.keys(AGENT_COMPATIBILITY)) {
|
||||
assert.ok(capabilityProbes(kind).length > 0, kind)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
normalizeExternalRunRef,
|
||||
outcomeForAcpStopReason,
|
||||
requireTerminalAgentResult,
|
||||
terminalAuthenticationDiagnostic,
|
||||
} = require('../../src/agents/agent-runtime-contract.cjs')
|
||||
const { ALLOWED_KINDS } = require('../../src/agents/cli/cli-discovery.cjs')
|
||||
|
||||
@@ -98,3 +99,21 @@ test('failed and cancelled outcomes do not become successful results', () => {
|
||||
&& error.failure.category === 'cancellation',
|
||||
)
|
||||
})
|
||||
|
||||
test('short terminal HTTP credential diagnostics cannot become successful results', () => {
|
||||
const diagnostic = 'HTTP 401: Invalid token (request id: request-1)'
|
||||
assert.equal(terminalAuthenticationDiagnostic(diagnostic), diagnostic)
|
||||
assert.equal(
|
||||
terminalAuthenticationDiagnostic('HTTP 403: Forbidden'),
|
||||
'HTTP 403: Forbidden',
|
||||
)
|
||||
assert.throws(
|
||||
() => requireTerminalAgentResult({ outcome: 'completed', text: diagnostic }),
|
||||
error => error.message === 'LOCAL_AGENT_AUTH_REQUIRED'
|
||||
&& error.failure.category === 'authentication'
|
||||
&& error.diagnostic === diagnostic,
|
||||
)
|
||||
assert.equal(terminalAuthenticationDiagnostic(
|
||||
'When an API returns HTTP 401: Invalid token, refresh the configured credential.',
|
||||
), '')
|
||||
})
|
||||
@@ -151,7 +151,7 @@ test('WorkBuddy uses non-interactive output and resumes its native session', ()
|
||||
assert.deepEqual(spec.args, [
|
||||
'--print', '--output-format', 'stream-json', '--include-partial-messages',
|
||||
'--permission-mode', 'plan',
|
||||
'--max-turns', '20', '--resume', 'workbuddy-session',
|
||||
'--max-turns', '8', '--resume', 'workbuddy-session',
|
||||
])
|
||||
assert.equal(spec.eventTransport, 'stream-json')
|
||||
})
|
||||
@@ -1596,6 +1596,14 @@ test('every supported built-in Agent produces an explicit completion outcome', (
|
||||
part: { type: 'step-finish', reason: 'stop' },
|
||||
}),
|
||||
].join('\n'),
|
||||
pi: [
|
||||
JSON.stringify({ type: 'session', version: 3, id: 'pi-session' }),
|
||||
JSON.stringify({
|
||||
type: 'message_update',
|
||||
assistantMessageEvent: { type: 'text_delta', contentIndex: 1, delta: 'Pi Agent reply' },
|
||||
}),
|
||||
JSON.stringify({ type: 'turn_end', stopReason: 'stop' }),
|
||||
].join('\n'),
|
||||
claude: JSON.stringify({
|
||||
type: 'result', result: 'Claude reply', session_id: 'claude-session',
|
||||
}),
|
||||
@@ -1635,6 +1643,155 @@ test('every supported built-in Agent produces an explicit completion outcome', (
|
||||
}
|
||||
})
|
||||
|
||||
test('structured result output keeps the terminal result after an earlier recoverable error', () => {
|
||||
for (const [kind, resultText] of [
|
||||
['claude', 'Claude completed after recovering'],
|
||||
['workbuddy', 'WorkBuddy completed after recovering'],
|
||||
['qwen', 'Qwen completed after recovering'],
|
||||
]) {
|
||||
const result = profileOutput(kind, [
|
||||
JSON.stringify({ type: 'error', error: { message: 'tool attempt failed' } }),
|
||||
JSON.stringify({
|
||||
type: 'result',
|
||||
result: resultText,
|
||||
session_id: `${kind}-session`,
|
||||
}),
|
||||
].join('\n'))
|
||||
assert.equal(result.outcome, 'completed', kind)
|
||||
assert.equal(result.text, resultText, kind)
|
||||
}
|
||||
})
|
||||
|
||||
test('a Claude terminal result settles even when the native process forgets to exit', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-claude-terminal-hang-'))
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const cli = executable(directory, 'claude-terminal-hang.cjs', `
|
||||
process.stdout.write(JSON.stringify({ type: 'result', result: 'Claude finished', session_id: 'claude-terminal-session' }) + '\\n')
|
||||
setInterval(() => {}, 1000)
|
||||
`)
|
||||
const startedAt = Date.now()
|
||||
const result = await runAgent(
|
||||
{ kind: 'claude', executable: cli, name: 'Claude' },
|
||||
'hello',
|
||||
directory,
|
||||
)
|
||||
assert.equal(result.outcome, 'completed')
|
||||
assert.equal(result.text, 'Claude finished')
|
||||
assert.equal(result.sessionRef, 'claude-terminal-session')
|
||||
assert.ok(Date.now() - startedAt < 5000)
|
||||
})
|
||||
|
||||
test('Pi removes only the recognizable internal collaboration prompt echo', () => {
|
||||
const raw = [
|
||||
JSON.stringify({ type: 'session', version: 3, id: 'pi-prompt-session' }),
|
||||
JSON.stringify({
|
||||
type: 'message_update',
|
||||
assistantMessageEvent: {
|
||||
type: 'text_delta',
|
||||
delta: 'You are participating in a local multi-agent discussion.\nUser task: sync skills\nAnswer the user naturally in Markdown.\nHere is the actual answer.',
|
||||
},
|
||||
}),
|
||||
JSON.stringify({ type: 'agent_end' }),
|
||||
].join('\n')
|
||||
const result = profileOutput('pi', raw)
|
||||
assert.equal(result.text, 'Here is the actual answer.')
|
||||
assert.equal(result.outcome, 'completed')
|
||||
})
|
||||
|
||||
test('runAgent strips an exact Meldwork direct-chat prompt echo before persisting the answer', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-prompt-echo-'))
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const prompt = [
|
||||
'You are participating in the local "Pi Agent" conversation as Pi Agent.',
|
||||
'Current user task (authoritative):',
|
||||
'hey',
|
||||
].join('\n')
|
||||
const cli = executable(directory, 'pi-prompt-echo.cjs', `
|
||||
const prompt = process.argv.at(-1)
|
||||
process.stdout.write(JSON.stringify({ type: 'session', id: 'pi-prompt-echo-session' }) + '\\n')
|
||||
process.stdout.write(JSON.stringify({
|
||||
type: 'message_update',
|
||||
assistantMessageEvent: { type: 'text_delta', delta: prompt + 'Hey! What can I help you with today?' },
|
||||
}) + '\\n')
|
||||
process.stdout.write(JSON.stringify({ type: 'agent_end' }) + '\\n')
|
||||
`)
|
||||
const result = await runAgent(
|
||||
{ kind: 'pi', executable: cli, name: 'Pi Agent' },
|
||||
prompt,
|
||||
directory,
|
||||
)
|
||||
assert.equal(result.text, 'Hey! What can I help you with today?')
|
||||
assert.equal(result.outcome, 'completed')
|
||||
})
|
||||
|
||||
test('runAgent strips a full long multi-agent prompt echo from Pi output', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-pi-group-echo-'))
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const prompt = [
|
||||
'You are participating in a local multi-agent discussion.',
|
||||
'',
|
||||
'Your role this turn is worker.',
|
||||
'',
|
||||
'The current collaboration phase is proposal.',
|
||||
'',
|
||||
'User task:',
|
||||
'Share a short proposal.',
|
||||
'',
|
||||
'Answer the user naturally in Markdown. Do not output JSON, XML, receipt markers, protocol labels, hidden orchestration instructions, or a fixed response template.',
|
||||
'',
|
||||
'Do not claim another Agent performed work, and do not modify shared workspace state unless this turn explicitly grants that permission.',
|
||||
].join('\n')
|
||||
const cli = executable(directory, 'pi-group-prompt-echo.cjs', `
|
||||
const prompt = ${JSON.stringify(prompt)}
|
||||
process.stdout.write(JSON.stringify({ type: 'session', id: 'pi-group-echo-session' }) + '\\n')
|
||||
process.stdout.write(JSON.stringify({
|
||||
type: 'message_update',
|
||||
assistantMessageEvent: { type: 'text_delta', delta: prompt + '\\n\\nProposal: keep the shared task state explicit.' },
|
||||
}) + '\\n')
|
||||
process.stdout.write(JSON.stringify({ type: 'agent_end' }) + '\\n')
|
||||
`)
|
||||
const result = await runAgent(
|
||||
{ kind: 'pi', executable: cli, name: 'Pi Agent' },
|
||||
prompt,
|
||||
directory,
|
||||
)
|
||||
assert.equal(result.text, 'Proposal: keep the shared task state explicit.')
|
||||
assert.equal(result.outcome, 'completed')
|
||||
})
|
||||
|
||||
test('runAgent strips partial Pi protocol and Harness record echoes', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-pi-partial-echo-'))
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const prompt = 'Continue the current group discussion using the Harness context below.'
|
||||
const cli = executable(directory, 'pi-partial-prompt-echo.cjs', `
|
||||
process.stdout.write(JSON.stringify({ type: 'session', id: 'pi-partial-echo-session' }) + '\\n')
|
||||
process.stdout.write(JSON.stringify({
|
||||
type: 'message_update',
|
||||
assistantMessageEvent: { type: 'text_delta', delta: [
|
||||
'Do not output JSON, XML, receipt markers, protocol labels, hidden orchestration instructions, or a fixed response template.',
|
||||
'',
|
||||
'Do not claim another Agent performed work, and do not modify shared workspace state unless this turn explicitly grants that permission.',
|
||||
'',
|
||||
'A useful conclusion.',
|
||||
'',
|
||||
'Selected records from completed phases:',
|
||||
'MELDWORK_V4_COLLABORATION_PACKAGE_V1',
|
||||
'[index] hermes',
|
||||
'Artifact: artifact-123',
|
||||
'[proposal] agent=claude Completed the proposal phase. Continue with the next step.',
|
||||
].join('\\n') },
|
||||
}) + '\\n')
|
||||
process.stdout.write(JSON.stringify({ type: 'agent_end' }) + '\\n')
|
||||
`)
|
||||
const result = await runAgent(
|
||||
{ kind: 'pi', executable: cli, name: 'Pi Agent' },
|
||||
prompt,
|
||||
directory,
|
||||
)
|
||||
assert.equal(result.text, 'A useful conclusion.\n\nContinue with the next step.')
|
||||
assert.equal(result.outcome, 'completed')
|
||||
})
|
||||
|
||||
test('recorded fixtures cover every supported built-in output schema', () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(
|
||||
path.join(OUTPUT_FIXTURE_DIRECTORY, 'manifest.json'),
|
||||
@@ -1652,6 +1809,7 @@ test('recorded fixtures cover every supported built-in output schema', () => {
|
||||
workbuddy: 'WorkBuddy fixture reply',
|
||||
kimi: 'Kimi fixture reply',
|
||||
mimo: 'MiMo fixture reply',
|
||||
pi: 'Pi Agent fixture reply',
|
||||
claude: 'Claude fixture reply',
|
||||
gemini: 'Gemini fixture reply',
|
||||
opencode: 'OpenCode fixture reply',
|
||||
@@ -1807,6 +1965,44 @@ test('supported local CLIs run in the selected workdir and return native session
|
||||
}
|
||||
})
|
||||
|
||||
test('Pi runs in JSON mode and selects approval flags from the sandbox', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-pi-invocation-'))
|
||||
const workdir = fs.realpathSync(directory)
|
||||
const argvFile = path.join(directory, 'pi-argv.json')
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const cli = executable(directory, 'pi-argv.cjs', `
|
||||
const fs = require('node:fs')
|
||||
fs.writeFileSync(${JSON.stringify(argvFile)}, JSON.stringify(process.argv.slice(2)))
|
||||
process.stdout.write(JSON.stringify({ type: 'session', version: 3, id: 'pi-session' }) + '\\n')
|
||||
process.stdout.write(JSON.stringify({
|
||||
type: 'message_update',
|
||||
assistantMessageEvent: { type: 'text_delta', contentIndex: 1, delta: process.cwd() },
|
||||
}) + '\\n')
|
||||
process.stdout.write(JSON.stringify({ type: 'turn_end', stopReason: 'stop' }) + '\\n')
|
||||
`)
|
||||
|
||||
for (const sandbox of ['workspace-write', 'read-only']) {
|
||||
fs.writeFileSync(argvFile, '')
|
||||
const result = await runAgent(
|
||||
{ kind: 'pi', executable: cli, name: 'pi' },
|
||||
'hello',
|
||||
workdir,
|
||||
{ sandbox, sessionRef: 'pi-native-session' },
|
||||
)
|
||||
const argv = JSON.parse(fs.readFileSync(argvFile, 'utf8'))
|
||||
assert.equal(result.text, workdir, sandbox)
|
||||
assert.equal(result.sessionRef, 'pi-session', sandbox)
|
||||
assert.ok(argv.includes('--mode'), sandbox)
|
||||
assert.ok(argv.includes('json'), sandbox)
|
||||
assert.ok(argv.includes('--print'), sandbox)
|
||||
assert.ok(argv.includes('--session'), sandbox)
|
||||
assert.ok(argv.includes('pi-native-session'), sandbox)
|
||||
assert.ok(argv.indexOf('--mode') < argv.indexOf('--print'), sandbox)
|
||||
assert.ok(argv.includes(sandbox === 'workspace-write' ? '--approve' : '--no-approve'), sandbox)
|
||||
assert.equal(argv.includes(sandbox === 'workspace-write' ? '--no-approve' : '--approve'), false, sandbox)
|
||||
}
|
||||
})
|
||||
|
||||
test('every conversational built-in Agent emits its answer through the shared runtime event contract', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-agent-events-'))
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
|
||||
@@ -55,7 +55,7 @@ test('Agent discovery scans independent Agent kinds concurrently', async () => {
|
||||
const startedBeforeRelease = started.length
|
||||
release()
|
||||
assert.deepEqual(await detection, [])
|
||||
assert.equal(startedBeforeRelease, 11)
|
||||
assert.equal(startedBeforeRelease, 12)
|
||||
})
|
||||
|
||||
test('Hermes capability and ACP checks run concurrently without changing the result', async () => {
|
||||
@@ -979,6 +979,31 @@ setInterval(() => {}, 1000)
|
||||
await waitForExit(pid)
|
||||
})
|
||||
|
||||
test('runAgent converts a closed stdin pipe into a bounded send failure', async () => {
|
||||
const child = new EventEmitter()
|
||||
child.stdin = new PassThrough()
|
||||
child.stdout = new PassThrough()
|
||||
child.stderr = new PassThrough()
|
||||
let killed = false
|
||||
child.kill = () => {
|
||||
if (killed) return true
|
||||
killed = true
|
||||
process.nextTick(() => child.emit('close', 1, 'SIGTERM'))
|
||||
return true
|
||||
}
|
||||
process.nextTick(() => child.stdin.destroy(new Error('write EPIPE')))
|
||||
|
||||
await assert.rejects(
|
||||
runAgent(
|
||||
{ kind: 'codex', executable: '/tmp/codex', name: 'Codex' },
|
||||
'send this reliably',
|
||||
os.tmpdir(),
|
||||
{ spawnFn: () => child },
|
||||
),
|
||||
error => error?.message === 'LOCAL_AGENT_SEND_FAILED',
|
||||
)
|
||||
})
|
||||
|
||||
test('runAgent cancellation closes pipes inherited by a descendant process', {
|
||||
skip: process.platform === 'win32',
|
||||
}, async (t) => {
|
||||
|
||||
@@ -495,6 +495,31 @@ finish()
|
||||
), result.text)
|
||||
})
|
||||
|
||||
test('structured terminal output is kept when a CLI exits non-zero after producing a result', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-cli-result-before-exit-'))
|
||||
const cli = executable(directory, 'result-before-exit.cjs', `
|
||||
process.stdout.write(JSON.stringify({
|
||||
type: 'result', subtype: 'success', is_error: false,
|
||||
result: 'completed before cleanup failure', session_id: 'result-before-exit-session',
|
||||
}) + '\\n')
|
||||
process.exitCode = 1
|
||||
`)
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
|
||||
const events = []
|
||||
const result = await runAgent(
|
||||
{ kind: 'workbuddy', executable: cli, name: 'WorkBuddy' },
|
||||
'hello',
|
||||
directory,
|
||||
{ onEvent: event => events.push(event) },
|
||||
)
|
||||
|
||||
assert.equal(result.outcome, 'completed')
|
||||
assert.equal(result.text, 'completed before cleanup failure')
|
||||
assert.equal(result.sessionRef, 'result-before-exit-session')
|
||||
assert.equal(events.some(event => event.type === 'answer_delta'), true)
|
||||
})
|
||||
|
||||
test('Gemini profile state retains one real tool lifecycle id and summary', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-gemini-profile-state-'))
|
||||
const cli = executable(directory, 'gemini-profile-state.cjs', `
|
||||
|
||||
@@ -46,6 +46,11 @@ const PROFILE_EXPECTATIONS = {
|
||||
source: 'stdout', answerMode: 'delta', tools: { start: false, update: false, result: false },
|
||||
plan: false, reasoning: false, session: true, terminal: true,
|
||||
},
|
||||
pi: {
|
||||
profileId: 'pi-json-events-v2', protocol: 'pi-json', framing: 'jsonl',
|
||||
source: 'stdout', answerMode: 'delta', tools: { start: true, update: true, result: true },
|
||||
plan: false, reasoning: true, session: true, terminal: true,
|
||||
},
|
||||
claude: {
|
||||
profileId: 'anthropic-stream-json-v1', protocol: 'anthropic-stream-json', framing: 'jsonl',
|
||||
source: 'stdout', answerMode: 'delta', tools: { start: true, update: true, result: true },
|
||||
@@ -128,6 +133,7 @@ test('every built-in invocation transport resolves its reusable profile and capa
|
||||
kind: 'mimo', options: { invocationTransport: 'json' },
|
||||
profileId: 'mimo-json-events-v1', limited: true,
|
||||
},
|
||||
{ kind: 'pi', profileId: 'pi-json-events-v2', limited: false },
|
||||
{ kind: 'claude', profileId: 'anthropic-stream-json-v1', limited: false },
|
||||
{ kind: 'gemini', profileId: 'gemini-stream-json-v1', limited: false },
|
||||
{ kind: 'opencode', profileId: 'acp-jsonrpc-v1', limited: false },
|
||||
|
||||
@@ -51,9 +51,15 @@ input.on('line', (line) => {
|
||||
} else if (message.method === 'session/prompt') {
|
||||
promptCount += 1
|
||||
if (resumed) {
|
||||
const prompt = message.params?.prompt?.[0]?.text || ''
|
||||
update(sessionId, {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'HTTP 401: Invalid token' },
|
||||
content: {
|
||||
type: 'text',
|
||||
text: prompt === 'auth prompt'
|
||||
? 'HTTP 401: Invalid token (request id: request-1)'
|
||||
: 'resumed turn',
|
||||
},
|
||||
})
|
||||
send({ jsonrpc: '2.0', id: message.id, result: { stopReason: 'end_turn' } })
|
||||
return
|
||||
@@ -120,12 +126,22 @@ input.on('line', (line) => {
|
||||
assert.equal(typeof adapters.shutdownAcpSessionRuntime, 'function')
|
||||
await adapters.shutdownAcpSessionRuntime()
|
||||
await within(readWhenReady(stoppedFile))
|
||||
await assert.rejects(() => adapters.runAgent(agent, 'stale prompt', directory, {
|
||||
const resumed = await adapters.runAgent(agent, 'resumed prompt', directory, {
|
||||
acpPersistenceKey: 'workspace:hermes:direct',
|
||||
sessionRef: first.sessionRef,
|
||||
sessionTransport: 'acp',
|
||||
env: { MELDWORK_TEST_SPAWN_COUNT_FILE: spawnCountFile },
|
||||
}), { message: 'LOCAL_AGENT_SESSION_INVALID' })
|
||||
})
|
||||
assert.equal(resumed.text, 'resumed turn')
|
||||
assert.equal(resumed.sessionRef, first.sessionRef)
|
||||
assert.equal(fs.readFileSync(spawnCountFile, 'utf8'), '2')
|
||||
await assert.rejects(() => adapters.runAgent(agent, 'auth prompt', directory, {
|
||||
acpPersistenceKey: 'workspace:hermes:direct',
|
||||
sessionRef: first.sessionRef,
|
||||
sessionTransport: 'acp',
|
||||
env: { MELDWORK_TEST_SPAWN_COUNT_FILE: spawnCountFile },
|
||||
}), error => error.message === 'LOCAL_AGENT_AUTH_REQUIRED'
|
||||
&& error.failure?.category === 'authentication')
|
||||
})
|
||||
|
||||
test('ACP closes a start-only tool with the partial terminal outcome', async (t) => {
|
||||
|
||||
@@ -51,6 +51,15 @@ const STREAMING_FIXTURES = [
|
||||
file: 'mimo.jsonl',
|
||||
expectedTypes: ['answer_delta'],
|
||||
},
|
||||
{
|
||||
kind: 'pi',
|
||||
agent: 'Pi Agent',
|
||||
version: '0.84.2',
|
||||
protocol: 'pi-json',
|
||||
evidence: 'captured-fixture',
|
||||
file: 'pi.jsonl',
|
||||
expectedTypes: ['reasoning_summary', 'reasoning_summary', 'reasoning_summary', 'answer_delta'],
|
||||
},
|
||||
{
|
||||
kind: 'opencode',
|
||||
agent: 'OpenCode',
|
||||
@@ -640,7 +649,7 @@ test('profile capabilities produce connector-limited warnings without Agent-kind
|
||||
}
|
||||
|
||||
const complete = [
|
||||
['codex'], ['hermes'], ['openclaw'], ['kimi'], ['claude'], ['qwen'], ['gemini'],
|
||||
['codex'], ['hermes'], ['openclaw'], ['pi', { transport: 'acp' }], ['kimi'], ['claude'], ['qwen'], ['gemini'],
|
||||
['workbuddy', { transport: 'stream-json' }],
|
||||
]
|
||||
for (const [kind, options] of complete) {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const net = require('node:net')
|
||||
const { spawn } = require('node:child_process')
|
||||
|
||||
const {
|
||||
discoverAgents,
|
||||
invocationFor,
|
||||
parentSessionDetached,
|
||||
parseAgentOutput,
|
||||
} = require('../../../src/agents/cloud/cloud-agent-bridge-server.cjs')
|
||||
|
||||
test('detects a Bridge parent process detached from its SSH session', () => {
|
||||
assert.equal(parentSessionDetached(123, () => '123 (node) S 456 0 0 0'), false)
|
||||
assert.equal(parentSessionDetached(123, () => '123 (node) S 1 0 0 0'), true)
|
||||
assert.equal(parentSessionDetached(123, () => { throw new Error('gone') }), true)
|
||||
})
|
||||
|
||||
test('starts the Bridge when the module is executed through node stdin', async () => {
|
||||
const port = await new Promise((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const selected = server.address().port
|
||||
server.close(error => error ? reject(error) : resolve(selected))
|
||||
})
|
||||
})
|
||||
const script = fs.readFileSync(
|
||||
require.resolve('../../../src/agents/cloud/cloud-agent-bridge-server.cjs'),
|
||||
'utf8',
|
||||
)
|
||||
const child = spawn(process.execPath, ['-'], {
|
||||
env: { ...process.env, MELDWORK_AGENT_BRIDGE_PORT: String(port) },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
try {
|
||||
const ready = new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('Bridge did not start')), 3000)
|
||||
child.once('error', reject)
|
||||
child.stdout.on('data', (chunk) => {
|
||||
if (!String(chunk).includes(`MELDWORK_AGENT_BRIDGE_READY ${port}`)) return
|
||||
clearTimeout(timer)
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
child.stdin.end(script)
|
||||
assert.equal(await ready, true)
|
||||
} finally {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
})
|
||||
|
||||
test('discovers supported remote CLIs and keeps an unauthenticated CLI visible but unavailable', () => {
|
||||
const outputs = new Map([
|
||||
['codex --version', { status: 0, stdout: 'codex-cli 0.146.1\n' }],
|
||||
['codex login status', { status: 0, stdout: 'Logged in using an API key\n' }],
|
||||
['hermes --version', { status: 0, stdout: 'Hermes Agent v0.19.1 (2026.7.30)\n' }],
|
||||
['claude --version', { status: 0, stdout: '2.1.123 (Claude Code)\n' }],
|
||||
['claude auth status', { status: 0, stdout: '{"loggedIn":false,"authMethod":"none"}\n' }],
|
||||
])
|
||||
const agents = discoverAgents({
|
||||
spawnSync: (command, args) => outputs.get(`${command} ${args.join(' ')}`)
|
||||
|| { status: 127, stdout: '' },
|
||||
listSkills: kind => kind === 'hermes'
|
||||
? [{ namespace: 'hermes', slug: 'research', name: 'research' }]
|
||||
: [],
|
||||
})
|
||||
|
||||
assert.deepEqual(agents.map(agent => [agent.id, agent.version, agent.available]), [
|
||||
['codex', '0.146.1', true],
|
||||
['hermes', '0.19.1', true],
|
||||
['claude', '2.1.123', false],
|
||||
])
|
||||
assert.deepEqual(agents[1].skills, [{ namespace: 'hermes', slug: 'research', name: 'research' }])
|
||||
assert.equal(agents[2].credentialState, 'missing')
|
||||
})
|
||||
|
||||
test('builds read-only invocations and parses Hermes and Claude output', () => {
|
||||
assert.deepEqual(invocationFor('codex', 'hello', 'thread-1'), {
|
||||
command: 'codex',
|
||||
args: ['exec', 'resume', '--json', '--skip-git-repo-check', '--sandbox', 'read-only', 'thread-1', '-'],
|
||||
input: 'hello',
|
||||
})
|
||||
assert.deepEqual(invocationFor('hermes', 'hello', ''), {
|
||||
command: 'hermes',
|
||||
args: ['chat', '--quiet', '--query', 'hello'],
|
||||
input: '',
|
||||
})
|
||||
assert.deepEqual(invocationFor('claude', 'hello', 'session-1'), {
|
||||
command: 'claude',
|
||||
args: [
|
||||
'--print', '--output-format', 'json', '--permission-mode', 'plan',
|
||||
'--resume', 'session-1', 'hello',
|
||||
],
|
||||
input: '',
|
||||
})
|
||||
assert.deepEqual(parseAgentOutput('hermes', 'session_id: hermes-1\nremote answer\n'), {
|
||||
text: 'remote answer', sessionRef: 'hermes-1', outcome: 'completed',
|
||||
})
|
||||
assert.deepEqual(parseAgentOutput('codex', [
|
||||
JSON.stringify({ type: 'thread.started', thread_id: 'thread-1' }),
|
||||
JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'codex answer' } }),
|
||||
].join('\n')), {
|
||||
text: 'codex answer', sessionRef: 'thread-1', outcome: 'completed',
|
||||
})
|
||||
assert.deepEqual(parseAgentOutput('claude', JSON.stringify({
|
||||
type: 'result', subtype: 'success', is_error: false,
|
||||
result: 'claude answer', session_id: 'claude-1',
|
||||
})), {
|
||||
text: 'claude answer', sessionRef: 'claude-1', outcome: 'completed',
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,387 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const {
|
||||
CloudAgentBridgeStore,
|
||||
loginShellCommand,
|
||||
manifestFor,
|
||||
normalizeAddress,
|
||||
} = require('../../../src/agents/cloud/cloud-agent-bridge.cjs')
|
||||
|
||||
test('runs fixed SSH bridge commands inside the remote login environment', () => {
|
||||
assert.equal(loginShellCommand('exec node -'), "bash -lc 'exec node -'")
|
||||
assert.equal(
|
||||
loginShellCommand("printf '%s' ready"),
|
||||
"bash -lc 'printf '\"'\"'%s'\"'\"' ready'",
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps a cloud CLI request alive for the local Agent runtime timeout', () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-cloud-timeout-'))
|
||||
try {
|
||||
const store = new CloudAgentBridgeStore({
|
||||
storagePath: path.join(directory, 'bridges.json'),
|
||||
fetch: async () => response(manifest()),
|
||||
})
|
||||
assert.equal(store.timeoutMs, 3 * 60 * 1000)
|
||||
assert.equal(store.runTimeoutMs, 10 * 60 * 1000)
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('reports activity while a cloud Agent request is waiting for its final response', async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-cloud-activity-'))
|
||||
const activity = []
|
||||
try {
|
||||
const store = new CloudAgentBridgeStore({
|
||||
storagePath: path.join(directory, 'bridges.json'),
|
||||
runTimeoutMs: 1000,
|
||||
fetch: async (_url, options) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 275))
|
||||
return response({ text: 'remote reply', outcome: 'completed' })
|
||||
},
|
||||
})
|
||||
store.records = [store.normalizeRecord({
|
||||
bridgeId: 'cloud-bridge-0123456789abcdef01234567',
|
||||
address: 'https://203.0.113.10:8443',
|
||||
label: 'Research server',
|
||||
available: true,
|
||||
agents: manifest().agents,
|
||||
})]
|
||||
const record = store.records[0]
|
||||
const result = await store.run({
|
||||
bridgeId: record.bridgeId,
|
||||
agentId: 'codex',
|
||||
prompt: 'hello',
|
||||
permissionMode: 'read-only',
|
||||
onActivity: () => activity.push(Date.now()),
|
||||
})
|
||||
assert.equal(result.text, 'remote reply')
|
||||
assert.ok(activity.length >= 2, `expected initial and heartbeat activity, got ${activity.length}`)
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function response(value, ok = true, status = 200) {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
text: async () => JSON.stringify(value),
|
||||
}
|
||||
}
|
||||
|
||||
function manifest() {
|
||||
return {
|
||||
protocol: 'meldwork-agent-bridge',
|
||||
version: 1,
|
||||
server: { id: 'server-1', label: 'Research server' },
|
||||
agents: [{
|
||||
id: 'codex',
|
||||
sourceKind: 'codex',
|
||||
label: 'Codex CLI',
|
||||
version: '0.1.0',
|
||||
available: true,
|
||||
credentialState: 'ready',
|
||||
skills: [{ namespace: 'codex', slug: 'review', name: 'Review' }],
|
||||
domains: ['general'],
|
||||
inputTypes: ['text'],
|
||||
permissionModes: ['read-only', 'workspace-write'],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
test('normalizes an IP HTTPS address and rejects non-IP destinations', () => {
|
||||
assert.equal(normalizeAddress('https://203.0.113.10:8443/'), 'https://203.0.113.10:8443')
|
||||
assert.equal(normalizeAddress('203.0.113.10:8443'), 'https://203.0.113.10:8443')
|
||||
assert.throws(() => normalizeAddress('http://203.0.113.10:8443'), {
|
||||
code: 'CLOUD_AGENT_BRIDGE_ADDRESS_INVALID',
|
||||
})
|
||||
assert.throws(() => normalizeAddress('https://agent.example.com'), {
|
||||
code: 'CLOUD_AGENT_BRIDGE_ADDRESS_INVALID',
|
||||
})
|
||||
})
|
||||
|
||||
test('connects, persists, and exposes a bridge Agent as a connector entry', async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-cloud-bridge-'))
|
||||
const storagePath = path.join(directory, 'bridges.json')
|
||||
const calls = []
|
||||
const store = new CloudAgentBridgeStore({
|
||||
storagePath,
|
||||
fetch: async (url, options) => {
|
||||
calls.push({ url, options })
|
||||
if (options?.method === 'POST') return response({ text: 'remote reply', outcome: 'completed' })
|
||||
return response(manifest())
|
||||
},
|
||||
})
|
||||
try {
|
||||
const connected = await store.connect({ address: 'https://203.0.113.10:8443' })
|
||||
assert.equal(connected.label, 'Research server')
|
||||
assert.equal(connected.available, true)
|
||||
assert.deepEqual(connected.agents.map(agent => agent.id), ['codex'])
|
||||
assert.equal(connected.agents[0].sourceKind, 'codex')
|
||||
assert.equal(connected.agents[0].credentialState, 'ready')
|
||||
assert.deepEqual(connected.agents[0].skills, [
|
||||
{ namespace: 'codex', slug: 'review', name: 'Review' },
|
||||
])
|
||||
|
||||
const [entry] = store.connectorEntries()
|
||||
assert.equal(entry.agent.id, 'codex')
|
||||
assert.equal(entry.manifest.transport.type, 'http')
|
||||
assert.equal(entry.manifest.invocation.recipeId, 'external.cloud-agent-bridge')
|
||||
assert.equal(entry.instance.bridgeId, connected.bridgeId)
|
||||
assert.match(entry.manifest.manifestId, /^connector-manifest-[a-f0-9]{64}$/)
|
||||
|
||||
const result = await store.run({
|
||||
bridgeId: connected.bridgeId,
|
||||
agentId: 'codex',
|
||||
prompt: 'hello',
|
||||
permissionMode: 'read-only',
|
||||
operationId: 'operation-1',
|
||||
})
|
||||
assert.deepEqual(result, { text: 'remote reply', outcome: 'completed' })
|
||||
assert.equal(calls.at(-1).url, 'https://203.0.113.10:8443/v1/agents/codex/runs')
|
||||
|
||||
const restarted = new CloudAgentBridgeStore({
|
||||
storagePath,
|
||||
fetch: async () => response(manifest()),
|
||||
})
|
||||
assert.equal(restarted.list()[0].bridgeId, connected.bridgeId)
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps installed but unavailable cloud Agents in the catalog and out of runtime entries', async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-cloud-catalog-'))
|
||||
const storagePath = path.join(directory, 'bridges.json')
|
||||
const unavailableManifest = manifest()
|
||||
unavailableManifest.agents.push({
|
||||
id: 'claude', sourceKind: 'claude', label: 'Claude Code', version: '2.1.123',
|
||||
available: false, credentialState: 'missing', skills: [], domains: ['general'],
|
||||
inputTypes: ['text'], permissionModes: ['read-only'],
|
||||
})
|
||||
const store = new CloudAgentBridgeStore({
|
||||
storagePath,
|
||||
fetch: async () => response(unavailableManifest),
|
||||
})
|
||||
try {
|
||||
await store.connect({ address: 'https://203.0.113.10:8443' })
|
||||
assert.deepEqual(store.connectorEntries().map(entry => entry.agent.id), ['codex'])
|
||||
assert.deepEqual(store.catalogEntries().map(agent => [agent.sourceKind, agent.available]), [
|
||||
['codex', true],
|
||||
['claude', false],
|
||||
])
|
||||
const claude = store.catalogEntries().find(agent => agent.sourceKind === 'claude')
|
||||
assert.equal(claude.credentialState, 'missing')
|
||||
assert.deepEqual(store.skillsForInstance(claude.kind), [])
|
||||
const codex = store.catalogEntries().find(agent => agent.sourceKind === 'codex')
|
||||
assert.deepEqual(store.skillsForInstance(codex.kind), [
|
||||
{ namespace: 'codex', slug: 'review', name: 'Review' },
|
||||
])
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('marks a persisted bridge unavailable after a failed refresh', async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-cloud-bridge-'))
|
||||
const storagePath = path.join(directory, 'bridges.json')
|
||||
const store = new CloudAgentBridgeStore({
|
||||
storagePath,
|
||||
fetch: async () => response(manifest()),
|
||||
})
|
||||
try {
|
||||
const connected = await store.connect({ address: 'https://198.51.100.12' })
|
||||
store.fetch = async () => response({ error: 'offline' }, false, 503)
|
||||
const [refreshed] = await store.refresh()
|
||||
assert.equal(refreshed.bridgeId, connected.bridgeId)
|
||||
assert.equal(refreshed.available, false)
|
||||
assert.equal(refreshed.lastError, 'CLOUD_AGENT_BRIDGE_HTTP_503')
|
||||
assert.deepEqual(store.connectorEntries(), [])
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('generates a valid manifest from a persisted bridge record', () => {
|
||||
const manifestRecord = manifestFor({
|
||||
bridgeId: 'cloud-bridge-0123456789abcdef01234567',
|
||||
address: 'https://203.0.113.10:8443',
|
||||
label: 'Server',
|
||||
}, {
|
||||
id: 'codex', label: 'Codex', version: '1.0.0', domains: ['general'],
|
||||
inputTypes: ['text'], permissionModes: ['read-only'],
|
||||
session: { supported: true, resume: true, cancel: true, checkpoint: false },
|
||||
})
|
||||
assert.equal(manifestRecord.connectorVersion, '1.0.0')
|
||||
assert.deepEqual(manifestRecord.outboundDestinations, ['https://203.0.113.10:8443'])
|
||||
})
|
||||
|
||||
test('discovers and runs a remote Codex CLI through existing SSH configuration', async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-cloud-ssh-'))
|
||||
const storagePath = path.join(directory, 'bridges.json')
|
||||
const calls = []
|
||||
const store = new CloudAgentBridgeStore({
|
||||
storagePath,
|
||||
fetch: async () => response(manifest()),
|
||||
sshTunnelStart: async () => { throw new Error('tunnel mocked unavailable') },
|
||||
sshExecute: async input => {
|
||||
calls.push(input)
|
||||
if (input.command.includes('codex --version')) return { stdout: 'codex-cli 0.121.0\n', stderr: '' }
|
||||
return {
|
||||
stdout: [
|
||||
JSON.stringify({ type: 'thread.started', thread_id: 'remote-thread-1' }),
|
||||
JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'remote reply' } }),
|
||||
JSON.stringify({ type: 'turn.completed' }),
|
||||
].join('\n'),
|
||||
stderr: '',
|
||||
}
|
||||
},
|
||||
})
|
||||
try {
|
||||
const connected = await store.connect({ address: '10.1.132.21' })
|
||||
assert.equal(connected.transport, 'ssh')
|
||||
assert.equal(connected.address, '10.1.132.21')
|
||||
assert.deepEqual(connected.agents.map(agent => agent.label), ['Codex CLI'])
|
||||
|
||||
const [entry] = store.connectorEntries()
|
||||
assert.equal(entry.manifest.invocation.recipeId, 'external.cloud-agent-ssh')
|
||||
assert.deepEqual(entry.manifest.permissionModes, ['read-only'])
|
||||
assert.deepEqual(entry.manifest.outboundDestinations, [])
|
||||
|
||||
const result = await store.run({
|
||||
bridgeId: connected.bridgeId,
|
||||
agentId: 'codex',
|
||||
prompt: 'hello remotely',
|
||||
permissionMode: 'read-only',
|
||||
})
|
||||
assert.deepEqual(result, { text: 'remote reply', sessionRef: '', outcome: 'completed' })
|
||||
assert.equal(calls[0].command.includes('codex --version'), true)
|
||||
assert.equal(calls[1].command, 'exec codex exec --json --skip-git-repo-check --sandbox read-only -')
|
||||
assert.equal(calls[1].input, 'hello remotely')
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('upgrades a persisted direct SSH connection to the enhanced tunnel without changing its id', async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-cloud-ssh-upgrade-'))
|
||||
const storagePath = path.join(directory, 'bridges.json')
|
||||
const store = new CloudAgentBridgeStore({
|
||||
storagePath,
|
||||
fetch: async () => response(manifest()),
|
||||
sshTunnelStart: async () => { throw new Error('initial tunnel unavailable') },
|
||||
sshExecute: async () => ({ stdout: 'codex-cli 0.121.0\n', stderr: '' }),
|
||||
})
|
||||
try {
|
||||
const connected = await store.connect({ address: '10.1.132.21' })
|
||||
assert.equal(connected.transport, 'ssh')
|
||||
store.sshTunnelStart = async () => ({
|
||||
child: { killed: false, kill() { this.killed = true }, once() {} },
|
||||
endpoint: 'http://127.0.0.1:45678',
|
||||
})
|
||||
|
||||
const [upgraded] = await store.refresh()
|
||||
|
||||
assert.equal(upgraded.bridgeId, connected.bridgeId)
|
||||
assert.equal(upgraded.transport, 'ssh-tunnel')
|
||||
assert.equal(upgraded.agents[0].sourceKind, 'codex')
|
||||
} finally {
|
||||
store.close()
|
||||
fs.rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps an SSH discovery failure when the HTTPS fallback is unavailable', async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-cloud-ssh-failure-'))
|
||||
const store = new CloudAgentBridgeStore({
|
||||
storagePath: path.join(directory, 'bridges.json'),
|
||||
fetch: async () => { throw new Error('network unavailable') },
|
||||
sshTunnelStart: async () => { throw new Error('tunnel mocked unavailable') },
|
||||
sshExecute: async () => ({ stdout: '', stderr: '' }),
|
||||
})
|
||||
try {
|
||||
await assert.rejects(store.connect({ address: '10.1.132.21' }), {
|
||||
code: 'CLOUD_AGENT_SSH_NO_AGENT',
|
||||
})
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('connects through an SSH-local Agent Bridge tunnel and reuses its HTTP protocol', async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-cloud-tunnel-'))
|
||||
const storagePath = path.join(directory, 'bridges.json')
|
||||
const calls = []
|
||||
const child = { killed: false, kill() { this.killed = true } }
|
||||
const store = new CloudAgentBridgeStore({
|
||||
storagePath,
|
||||
fetch: async (url, options) => {
|
||||
calls.push({ url, options })
|
||||
if (options?.method === 'POST') return response({ text: 'tunneled reply', outcome: 'completed' })
|
||||
return response(manifest())
|
||||
},
|
||||
sshTunnelStart: async () => ({ child, endpoint: 'http://127.0.0.1:45678' }),
|
||||
})
|
||||
try {
|
||||
const connected = await store.connect({ address: '10.1.132.21', label: 'SSH tunnel server' })
|
||||
assert.equal(connected.transport, 'ssh-tunnel')
|
||||
assert.equal(connected.address, '10.1.132.21')
|
||||
const result = await store.run({
|
||||
bridgeId: connected.bridgeId,
|
||||
agentId: 'codex',
|
||||
prompt: 'through tunnel',
|
||||
permissionMode: 'read-only',
|
||||
operationId: 'tunnel-operation-1',
|
||||
})
|
||||
assert.deepEqual(result, { text: 'tunneled reply', outcome: 'completed' })
|
||||
assert.equal(calls.at(-1).url, 'http://127.0.0.1:45678/v1/agents/codex/runs')
|
||||
store.remove(connected.bridgeId)
|
||||
assert.equal(child.killed, true)
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('passes a caller abort signal through to cloud Agent execution', async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-cloud-bridge-'))
|
||||
const storagePath = path.join(directory, 'bridges.json')
|
||||
const controller = new AbortController()
|
||||
let receivedSignal = null
|
||||
let resolveRun = null
|
||||
const store = new CloudAgentBridgeStore({
|
||||
storagePath,
|
||||
fetch: async (url, options) => {
|
||||
receivedSignal = options.signal
|
||||
if (options.method === 'POST') {
|
||||
return new Promise(resolve => { resolveRun = () => resolve(response({ text: 'done', outcome: 'completed' })) })
|
||||
}
|
||||
return response(manifest())
|
||||
},
|
||||
})
|
||||
try {
|
||||
const connected = await store.connect({ address: 'https://203.0.113.10:8443' })
|
||||
const runPromise = store.run({
|
||||
bridgeId: connected.bridgeId,
|
||||
agentId: 'codex',
|
||||
prompt: 'cancel me',
|
||||
permissionMode: 'read-only',
|
||||
operationId: 'operation-2',
|
||||
signal: controller.signal,
|
||||
})
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.notEqual(receivedSignal, controller.signal)
|
||||
assert.equal(receivedSignal.aborted, false)
|
||||
controller.abort()
|
||||
assert.equal(receivedSignal.aborted, true)
|
||||
resolveRun()
|
||||
await runPromise
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -5,6 +5,7 @@ const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
|
||||
const { createAgentConnectorManifest, serializeAgentConnectorManifest } = require('../../../src/agents/connectors/agent-connector-manifest.cjs')
|
||||
const { manifestFor } = require('../../../src/agents/cloud/cloud-agent-bridge.cjs')
|
||||
const { AgentConnectorInstanceStore } = require('../../../src/agents/connectors/agent-connector-instance-store.cjs')
|
||||
const { isSupportedAgentKind } = require('../../../src/workspace/local-workspace-contracts.cjs')
|
||||
const {
|
||||
@@ -54,6 +55,7 @@ function fixture(t, options = {}) {
|
||||
}
|
||||
},
|
||||
seedSample: options.seedSample,
|
||||
cloudBridges: options.cloudBridges,
|
||||
})
|
||||
return {
|
||||
calls,
|
||||
@@ -118,6 +120,106 @@ test('discovers an explicitly installed approved sample and exposes generic sani
|
||||
assert.equal(calls[0][3].sandbox, 'read-only')
|
||||
})
|
||||
|
||||
test('discovers and runs an Agent exposed by a Cloud Agent Bridge', async (t) => {
|
||||
const record = {
|
||||
bridgeId: 'cloud-bridge-0123456789abcdef01234567',
|
||||
address: 'https://203.0.113.10:8443',
|
||||
label: 'Research server',
|
||||
available: true,
|
||||
}
|
||||
const agent = {
|
||||
id: 'codex', sourceKind: 'codex', label: 'Codex CLI', version: '0.146.1', description: '',
|
||||
credentialState: 'ready',
|
||||
domains: ['general'], inputTypes: ['text'], permissionModes: ['read-only'],
|
||||
session: { supported: true, resume: true, cancel: true, checkpoint: false },
|
||||
}
|
||||
const manifest = manifestFor(record, agent)
|
||||
const entry = {
|
||||
record, agent, manifest,
|
||||
instance: {
|
||||
instanceId: manifest.connectorId,
|
||||
connectorId: manifest.connectorId,
|
||||
connectorVersion: manifest.connectorVersion,
|
||||
upstreamVersion: '1.0.0',
|
||||
label: manifest.label,
|
||||
credentialRef: null,
|
||||
manifestId: manifest.manifestId,
|
||||
bridgeId: record.bridgeId,
|
||||
agentId: agent.id,
|
||||
},
|
||||
}
|
||||
let cloudRunInput
|
||||
const { connectors } = fixture(t, {
|
||||
cloudBridges: {
|
||||
connectorEntries: () => [entry],
|
||||
bridgeForInstance: instanceId => instanceId === entry.instance.instanceId ? entry : null,
|
||||
run: async (input) => {
|
||||
cloudRunInput = input
|
||||
return { text: 'cloud reply', outcome: 'completed' }
|
||||
},
|
||||
},
|
||||
})
|
||||
const [detected] = connectors.refresh([])
|
||||
assert.equal(detected.cloud, true)
|
||||
assert.equal(isSupportedAgentKind(detected.kind), true)
|
||||
const cloudCatalog = connectors.catalog()[0]
|
||||
assert.equal(cloudCatalog.cloud, true)
|
||||
assert.equal(cloudCatalog.custom, false)
|
||||
assert.equal(cloudCatalog.sourceKind, 'codex')
|
||||
assert.equal(cloudCatalog.version, '0.146.1')
|
||||
assert.equal(cloudCatalog.credentialState, 'ready')
|
||||
const result = await connectors.run(detected, 'hello cloud', '/tmp/workspace', {
|
||||
runId: 'run-cloud-1', agentRunId: 'agent-cloud-1', operationId: 'operation-cloud-1', sandbox: 'read-only',
|
||||
})
|
||||
assert.equal(result.text, 'cloud reply')
|
||||
assert.equal(result.outcome, 'completed')
|
||||
assert.equal(cloudRunInput.permissionMode, 'read-only')
|
||||
assert.deepEqual(result.connectorEventState.events.map(event => event.type), ['Completed'])
|
||||
})
|
||||
|
||||
test('runs a Codex Agent discovered through SSH without declaring an HTTP outbound destination', async (t) => {
|
||||
const record = {
|
||||
bridgeId: 'cloud-bridge-0123456789abcdef01234567',
|
||||
transport: 'ssh',
|
||||
address: '10.1.132.21',
|
||||
label: 'Research server',
|
||||
available: true,
|
||||
}
|
||||
const agent = {
|
||||
id: 'codex', label: 'Codex CLI', version: '0.121.0', description: '',
|
||||
domains: ['general'], inputTypes: ['text'], permissionModes: ['read-only'],
|
||||
session: { supported: false, resume: false, cancel: true, checkpoint: false },
|
||||
}
|
||||
const manifest = manifestFor(record, agent)
|
||||
const entry = {
|
||||
record, agent, manifest,
|
||||
instance: {
|
||||
instanceId: manifest.connectorId,
|
||||
connectorId: manifest.connectorId,
|
||||
connectorVersion: manifest.connectorVersion,
|
||||
upstreamVersion: '1.0.0',
|
||||
label: manifest.label,
|
||||
credentialRef: null,
|
||||
manifestId: manifest.manifestId,
|
||||
bridgeId: record.bridgeId,
|
||||
agentId: agent.id,
|
||||
},
|
||||
}
|
||||
const { connectors } = fixture(t, {
|
||||
cloudBridges: {
|
||||
connectorEntries: () => [entry],
|
||||
bridgeForInstance: instanceId => instanceId === entry.instance.instanceId ? entry : null,
|
||||
run: async () => ({ text: 'SSH reply', outcome: 'completed' }),
|
||||
},
|
||||
})
|
||||
const [detected] = connectors.refresh([])
|
||||
const result = await connectors.run(detected, 'hello SSH', '/tmp/workspace', {
|
||||
runId: 'run-cloud-ssh-1', agentRunId: 'agent-cloud-ssh-1', operationId: 'operation-cloud-ssh-1', sandbox: 'read-only',
|
||||
})
|
||||
assert.equal(result.text, 'SSH reply')
|
||||
assert.deepEqual(result.connectorEventState.events.map(event => event.type), ['Completed'])
|
||||
})
|
||||
|
||||
test('delegated local Connector receives a streaming profile event before completion', async (t) => {
|
||||
const releaseFile = path.join(os.tmpdir(), `meldwork-connector-release-${process.pid}-${Date.now()}`)
|
||||
t.after(async () => {
|
||||
|
||||
@@ -108,12 +108,12 @@ test('catalog reports installed, recommended and provider-compatible Agents', as
|
||||
compatibilityState: 'compatible', incompatibilityReason: '', incompatibilityProbe: '',
|
||||
},
|
||||
{
|
||||
kind: 'gemini', version: '', executable: '/tmp/gemini',
|
||||
kind: 'pi', version: '', executable: '/tmp/pi',
|
||||
compatibilityState: 'incompatible',
|
||||
incompatibilityReason: 'LOCAL_AGENT_VERSION_UNSUPPORTED',
|
||||
incompatibilityProbe: '',
|
||||
resolvedVersion: '',
|
||||
supportedVersionRange: '0.53.1',
|
||||
supportedVersionRange: '0.1.0..0.1.999',
|
||||
privateMetadata: 'must-not-cross-ipc',
|
||||
},
|
||||
],
|
||||
@@ -122,8 +122,8 @@ test('catalog reports installed, recommended and provider-compatible Agents', as
|
||||
const result = await service.catalog()
|
||||
assert.equal(result.platform, 'darwin')
|
||||
assert.deepEqual(result.agents.map(agent => agent.kind), [
|
||||
'hermes', 'openclaw', 'workbuddy', 'kimi', 'mimo', 'codex', 'claude',
|
||||
'gemini', 'opencode', 'qwen', 'opencodereview',
|
||||
'codex', 'claude', 'hermes', 'workbuddy', 'openclaw', 'pi',
|
||||
'opencode', 'opencodereview', 'kimi', 'mimo', 'qwen',
|
||||
])
|
||||
assert.equal(result.agents.find(agent => agent.kind === 'workbuddy').installed, true)
|
||||
assert.equal(result.agents.find(agent => agent.kind === 'workbuddy').providerCompatible, true)
|
||||
@@ -133,17 +133,17 @@ test('catalog reports installed, recommended and provider-compatible Agents', as
|
||||
'supported')
|
||||
assert.equal(result.agents.find(agent => agent.kind === 'kimi').providerCompatible, false)
|
||||
assert.equal(result.agents.find(agent => agent.kind === 'kimi').providerSupport, 'native-config')
|
||||
assert.equal(result.agents.find(agent => agent.kind === 'gemini').installed, true)
|
||||
assert.equal(result.agents.find(agent => agent.kind === 'gemini').version, '')
|
||||
assert.equal(result.agents.find(agent => agent.kind === 'pi').installed, true)
|
||||
assert.equal(result.agents.find(agent => agent.kind === 'pi').version, '')
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(Object.entries(result.agents.find(agent => agent.kind === 'gemini'))
|
||||
Object.fromEntries(Object.entries(result.agents.find(agent => agent.kind === 'pi'))
|
||||
.filter(([key]) => [
|
||||
'resolvedVersion', 'supportedVersionRange', 'compatibilityState',
|
||||
'incompatibilityReason', 'incompatibilityProbe',
|
||||
].includes(key))),
|
||||
{
|
||||
resolvedVersion: '',
|
||||
supportedVersionRange: '0.53.1',
|
||||
supportedVersionRange: '0.1.0..0.1.999',
|
||||
compatibilityState: 'incompatible',
|
||||
incompatibilityReason: 'LOCAL_AGENT_VERSION_UNSUPPORTED',
|
||||
incompatibilityProbe: '',
|
||||
@@ -165,7 +165,7 @@ test('catalog reports installed, recommended and provider-compatible Agents', as
|
||||
)
|
||||
assert.equal(result.agents.find(agent => agent.kind === 'openclaw').installSupported, true)
|
||||
assert.equal(result.agents.find(agent => agent.kind === 'openclaw').installErrorCode, '')
|
||||
assert.equal(result.agents.find(agent => agent.kind === 'gemini').providerSupport, 'native-config')
|
||||
assert.equal(result.agents.find(agent => agent.kind === 'pi').providerSupport, 'native-config')
|
||||
assert.equal(result.agents.find(agent => agent.kind === 'opencode').providerCompatible, false)
|
||||
assert.equal(result.agents.find(agent => agent.kind === 'opencodereview').providerCompatible, true)
|
||||
assert.equal(JSON.stringify(result).includes('/tmp/codebuddy'), false)
|
||||
@@ -271,7 +271,7 @@ test('cache invalidation does not let an older in-flight detection overwrite fre
|
||||
test('recipes are fixed by Agent and platform', () => {
|
||||
const npmKinds = [
|
||||
'codex', 'claude', 'openclaw', 'qwen', 'workbuddy', 'gemini',
|
||||
'opencode', 'kimi', 'mimo', 'opencodereview',
|
||||
'opencode', 'kimi', 'mimo', 'opencodereview', 'pi',
|
||||
]
|
||||
for (const platform of ['darwin', 'win32']) {
|
||||
for (const kind of npmKinds) {
|
||||
@@ -284,6 +284,14 @@ test('recipes are fixed by Agent and platform', () => {
|
||||
assert.equal(npmPackageSpec(recipe).includes('@latest'), false, kind)
|
||||
}
|
||||
}
|
||||
const pi = installRecipe('pi', 'darwin')
|
||||
assert.equal(pi.packageName, '@earendil-works/pi-coding-agent')
|
||||
assert.equal(pi.version, '0.84.2')
|
||||
assert.equal(
|
||||
pi.integrity,
|
||||
'sha512-l4E+B7hgXKWddRo8bC/eSue2aWZjEgJ9xIpf5p0Og+lq8a2TArCwJ0HCoCPCgaBP/tN4zbYH/wOwvx9pJpeLCA==',
|
||||
)
|
||||
assert.deepEqual(installRecipe('pi', 'win32'), pi)
|
||||
const darwinHermes = installRecipe('hermes', 'darwin')
|
||||
const windowsHermes = installRecipe('hermes', 'win32')
|
||||
assert.equal(darwinHermes.type, 'script')
|
||||
@@ -304,6 +312,31 @@ test('recipes are fixed by Agent and platform', () => {
|
||||
assert.equal(installRecipe('hermes', 'linux'), null)
|
||||
})
|
||||
|
||||
test('Pi installation disables package scripts and verifies the detected release', async () => {
|
||||
const recipe = installRecipe('pi', 'darwin')
|
||||
const calls = []
|
||||
let detectCount = 0
|
||||
const service = installer({
|
||||
detectAgents: async () => {
|
||||
detectCount += 1
|
||||
return detectCount > 1 ? [detectedRelease('pi')] : []
|
||||
},
|
||||
runProcess: async (...args) => { calls.push(args) },
|
||||
})
|
||||
|
||||
await service.start('pi')
|
||||
await service.waitForIdle()
|
||||
|
||||
assert.deepEqual(calls[0].slice(0, 2), [
|
||||
'/usr/local/bin/npm',
|
||||
[
|
||||
'install', '--global', '--ignore-scripts', npmPackageSpec(recipe),
|
||||
'--registry', 'https://registry.npmjs.org/',
|
||||
],
|
||||
])
|
||||
assert.equal(service.state().phase, 'completed')
|
||||
})
|
||||
|
||||
test('npm lookup uses the enhanced Finder-safe search path', async () => {
|
||||
const result = await defaultFindCommand('npm', 'darwin', {
|
||||
home: '/Users/Ryder',
|
||||
|
||||
@@ -170,6 +170,7 @@ test('native readiness recognizes credential files for every supported CLI', ()
|
||||
['hermes', ['.hermes', 'auth.json'], { access_token: 'hermes-secret' }],
|
||||
['openclaw', ['.openclaw', 'openclaw.json'], { gateway: { auth: { token: 'claw-secret' } } }],
|
||||
['workbuddy', ['.workbuddy', 'models.json'], [{ apiKey: 'workbuddy-secret' }]],
|
||||
['pi', ['.pi', 'auth.json'], { access_token: 'pi-secret' }],
|
||||
['kimi', ['.kimi-code', 'credentials', 'kimi-code.json'], { refresh_token: 'kimi-secret' }],
|
||||
['claude', ['.claude', '.credentials.json'], { oauth: { accessToken: 'claude-secret' } }],
|
||||
['qwen', ['.qwen', 'oauth_creds.json'], { access_token: 'qwen-secret' }],
|
||||
|
||||
@@ -489,27 +489,27 @@ test('allows only plan-ordered negotiated writer replacement while retaining ind
|
||||
targetKinds,
|
||||
assignments: [
|
||||
{ taskId: 'a-final', ownerKind: 'workbuddy', role: 'integrator', objective: 'Integrate work.', expectedOutput: 'Integrated Artifact.', inputRefs: [], artifactIds: [], dependsOn: ['b-qwen', 'c-codex', 'd-hermes'] },
|
||||
{ taskId: 'b-qwen', ownerKind: 'qwen', role: 'worker', objective: 'Prepare a non-verifier work package.', expectedOutput: 'Qwen Artifact.', inputRefs: [], artifactIds: [], dependsOn: [] },
|
||||
{ taskId: 'b-qwen', ownerKind: 'qwen', role: 'verifier', objective: 'Prepare Qwen evidence.', expectedOutput: 'Qwen Artifact.', inputRefs: [], artifactIds: [], dependsOn: [] },
|
||||
{ taskId: 'c-codex', ownerKind: 'codex', role: 'verifier', objective: 'Prepare Codex evidence.', expectedOutput: 'Codex Artifact.', inputRefs: [], artifactIds: [], dependsOn: [] },
|
||||
{ taskId: 'd-hermes', ownerKind: 'hermes', role: 'verifier', objective: 'Prepare Hermes evidence.', expectedOutput: 'Hermes Artifact.', inputRefs: [], artifactIds: [], dependsOn: [] },
|
||||
],
|
||||
finalizerKind: 'workbuddy',
|
||||
verifierKinds: ['codex', 'hermes'],
|
||||
verifierKinds: ['codex', 'hermes', 'qwen'],
|
||||
agreedBy: targetKinds,
|
||||
})
|
||||
const rankedKinds = ['workbuddy', 'qwen', 'codex', 'hermes']
|
||||
const rankedKinds = ['workbuddy', 'codex', 'hermes', 'qwen']
|
||||
const rankingFingerprint = hashValue({ planHash: coordinationPlan.planHash, rankedKinds })
|
||||
const originalSlot = base.slots.find(slot => slot.agentKind === 'workbuddy')
|
||||
const replacementSlot = base.slots.find(slot => slot.agentKind === 'qwen')
|
||||
const replacementSlot = base.slots.find(slot => slot.agentKind === 'codex')
|
||||
const recovery = {
|
||||
revision: 2,
|
||||
originalWriterKind: 'workbuddy',
|
||||
activeWriterKind: 'qwen',
|
||||
verificationKinds: ['codex', 'hermes'],
|
||||
activeWriterKind: 'codex',
|
||||
verificationKinds: ['hermes', 'qwen'],
|
||||
rankedKinds,
|
||||
rankingFingerprint,
|
||||
stateEpoch: 0,
|
||||
triedWriters: ['workbuddy', 'qwen'],
|
||||
triedWriters: ['workbuddy', 'codex'],
|
||||
attempts: [
|
||||
{
|
||||
attemptId: 'synthesis-attempt-workbuddy-1',
|
||||
@@ -525,8 +525,8 @@ test('allows only plan-ordered negotiated writer replacement while retaining ind
|
||||
updatedAt: 1001,
|
||||
},
|
||||
{
|
||||
attemptId: 'synthesis-attempt-qwen-1',
|
||||
writerKind: 'qwen',
|
||||
attemptId: 'synthesis-attempt-codex-1',
|
||||
writerKind: 'codex',
|
||||
slotId: replacementSlot.slotId,
|
||||
operationId: replacementSlot.operationId,
|
||||
attempt: 1,
|
||||
@@ -542,12 +542,12 @@ test('allows only plan-ordered negotiated writer replacement while retaining ind
|
||||
const record = {
|
||||
...base,
|
||||
phase: 'synthesis',
|
||||
currentKinds: ['qwen'],
|
||||
pendingKinds: ['qwen'],
|
||||
currentKinds: ['codex'],
|
||||
pendingKinds: ['codex'],
|
||||
coordinationPlan,
|
||||
synthesisRecovery: recovery,
|
||||
commitState: { ...base.commitState, writerKind: 'qwen' },
|
||||
slots: base.slots.map(slot => slot.agentKind === 'qwen' ? {
|
||||
commitState: { ...base.commitState, writerKind: 'codex' },
|
||||
slots: base.slots.map(slot => slot.agentKind === 'codex' ? {
|
||||
...slot,
|
||||
phase: 'synthesis',
|
||||
status: 'planned',
|
||||
@@ -563,7 +563,7 @@ test('allows only plan-ordered negotiated writer replacement while retaining ind
|
||||
assert.throws(() => parseOrchestrationV4({
|
||||
...record,
|
||||
slots: record.slots.map(slot => (
|
||||
['qwen', 'workbuddy'].includes(slot.agentKind)
|
||||
['codex', 'workbuddy'].includes(slot.agentKind)
|
||||
? { ...slot, permission: 'workspace-write' }
|
||||
: slot
|
||||
)),
|
||||
@@ -1960,7 +1960,7 @@ test('keeps 32 Agent members while allowing a bounded extra work package', () =>
|
||||
targetKinds,
|
||||
assignments: [...baseAssignments, extraAssignment],
|
||||
finalizerKind: targetKinds.at(-1),
|
||||
verifierKinds: targetKinds.slice(0, 2),
|
||||
verifierKinds: targetKinds.slice(0, -1),
|
||||
agreedBy: targetKinds,
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{ "kind": "hermes", "agent": "Hermes", "version": "0.19.1", "protocol": "terminal-text", "evidence": "captured-fixture", "file": "hermes.txt" },
|
||||
{ "kind": "openclaw", "transport": "legacy", "agent": "OpenClaw", "version": "2026.7.1-2", "protocol": "openclaw-terminal-document", "evidence": "captured-fixture", "file": "openclaw.json" },
|
||||
{ "kind": "workbuddy", "agent": "WorkBuddy", "version": "2.115.0", "protocol": "workbuddy-terminal-result-json", "evidence": "source-derived", "file": "workbuddy.jsonl", "variants": [{ "transport": "stream-json", "agent": "WorkBuddy", "version": "2.115.0", "protocol": "anthropic-stream-json", "evidence": "source-derived", "file": "workbuddy-stream-json.jsonl" }] },
|
||||
{ "kind": "pi", "agent": "Pi Agent", "version": "0.84.2", "protocol": "pi-json", "evidence": "captured-fixture", "file": "pi.jsonl" },
|
||||
{ "kind": "kimi", "transport": "stream-json", "agent": "Kimi", "version": "0.19.2", "protocol": "assistant-jsonl", "evidence": "captured-fixture", "file": "kimi.jsonl" },
|
||||
{ "kind": "mimo", "agent": "MiMo", "version": "0.1.0", "protocol": "mimo-json-events", "evidence": "captured-fixture", "file": "mimo.jsonl" },
|
||||
{ "kind": "claude", "agent": "Claude", "version": "2.1.165", "protocol": "anthropic-stream-json", "evidence": "captured-fixture", "file": "claude.jsonl" },
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{"type":"session","version":3,"id":"pi-fixture-session","timestamp":"2026-08-25T00:00:00.000Z","cwd":"/tmp/work"}
|
||||
{"type":"agent_start"}
|
||||
{"type":"turn_start"}
|
||||
{"type":"message_update","assistantMessageEvent":{"type":"thinking_start","contentIndex":0}}
|
||||
{"type":"message_update","assistantMessageEvent":{"type":"thinking_delta","contentIndex":0,"delta":"Inspecting the workspace."}}
|
||||
{"type":"message_update","assistantMessageEvent":{"type":"thinking_end","contentIndex":0,"content":"Inspecting the workspace."}}
|
||||
{"type":"message_update","assistantMessageEvent":{"type":"text_start","contentIndex":1}}
|
||||
{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":1,"delta":"Pi Agent fixture reply"}}
|
||||
{"type":"message_update","assistantMessageEvent":{"type":"text_end","contentIndex":1,"content":"Pi Agent fixture reply"}}
|
||||
{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"Pi Agent fixture reply"}],"stopReason":"stop"}}
|
||||
{"type":"turn_end","message":{"role":"assistant","stopReason":"stop"}}
|
||||
{"type":"agent_end"}
|
||||
@@ -1,22 +0,0 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { mediaGenerationRequest } = require('../../src/media/media-generation-request.cjs')
|
||||
|
||||
test('identifies explicit image, audio, and video generation requests', () => {
|
||||
assert.deepEqual(mediaGenerationRequest('请生成一张赛博朋克城市海报'), {
|
||||
type: 'image', prompt: '请生成一张赛博朋克城市海报',
|
||||
})
|
||||
assert.deepEqual(mediaGenerationRequest('Create a short video of an astronaut walking on Mars'), {
|
||||
type: 'video', prompt: 'Create a short video of an astronaut walking on Mars',
|
||||
})
|
||||
assert.deepEqual(mediaGenerationRequest('帮我制作一段中文配音音频'), {
|
||||
type: 'audio', prompt: '帮我制作一段中文配音音频',
|
||||
})
|
||||
})
|
||||
|
||||
test('does not turn capability questions into paid media requests', () => {
|
||||
assert.equal(mediaGenerationRequest('Codex 可以生成图片吗?'), null)
|
||||
assert.equal(mediaGenerationRequest('How to generate a video with Codex?'), null)
|
||||
assert.equal(mediaGenerationRequest('解释一下图片生成接口'), null)
|
||||
})
|
||||
@@ -1,402 +0,0 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const { MediaGenerationRuntime } = require('../../src/media/media-generation-runtime.cjs')
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
const MP3 = Buffer.from('49443304000000000000', 'hex')
|
||||
const MP4 = Buffer.concat([Buffer.alloc(4), Buffer.from('ftypisom'), Buffer.alloc(8)])
|
||||
|
||||
function response(body, options = {}) {
|
||||
return new Response(body, {
|
||||
status: options.status || 200,
|
||||
headers: options.headers || { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
function fixture(t, fetchFn, provider = {}) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-media-generation-'))
|
||||
const workdir = path.join(directory, 'workspace')
|
||||
fs.mkdirSync(workdir)
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const runtime = new MediaGenerationRuntime({
|
||||
getProvider: () => ({
|
||||
apiKey: 'test-api-key', baseUrl: 'https://api.openai.com/v1', model: 'gpt-5-codex',
|
||||
...provider,
|
||||
}),
|
||||
fetchFn,
|
||||
createId: () => 'media-id',
|
||||
pollIntervalMs: 0,
|
||||
})
|
||||
return { workdir, runtime }
|
||||
}
|
||||
|
||||
test('writes a base64 image response to the controlled output directory and traces it', async (t) => {
|
||||
const calls = []
|
||||
const { workdir, runtime } = fixture(t, async (url, init) => {
|
||||
calls.push([String(url), init])
|
||||
return response(JSON.stringify({ data: [{ b64_json: PNG.toString('base64') }] }))
|
||||
})
|
||||
const events = []
|
||||
const result = await runtime.generate({
|
||||
kind: 'codex', request: { type: 'image', prompt: 'a lighthouse at dawn' }, workdir,
|
||||
onEvent: event => events.push(event),
|
||||
})
|
||||
|
||||
assert.deepEqual(result, { type: 'image', filename: 'generated-image-media-id.png' })
|
||||
assert.deepEqual(fs.readFileSync(path.join(workdir, '.meldwork-output', result.filename)), PNG)
|
||||
assert.equal(calls[0][0], 'https://api.openai.com/v1/images/generations')
|
||||
assert.equal(JSON.parse(calls[0][1].body).model, 'gpt-image-1')
|
||||
assert.equal(calls[0][1].headers.Authorization, 'Bearer test-api-key')
|
||||
assert.deepEqual(events.map(event => [event.type, event.status, event.title]), [
|
||||
['tool_start', 'running', 'image_generation'],
|
||||
['tool_result_summary', 'completed', 'image_generation'],
|
||||
])
|
||||
})
|
||||
|
||||
test('accepts non-Codex Agents and resolves media independently from the chat runtime', async (t) => {
|
||||
const providerRequests = []
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-media-generation-'))
|
||||
const workdir = path.join(directory, 'workspace')
|
||||
fs.mkdirSync(workdir)
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const runtime = new MediaGenerationRuntime({
|
||||
getProvider: (kind, type) => {
|
||||
providerRequests.push([kind, type])
|
||||
return { apiKey: 'shared-key', baseUrl: 'https://api.openai.com/v1', model: 'chat-model' }
|
||||
},
|
||||
fetchFn: async () => response(JSON.stringify({ data: [{ b64_json: PNG.toString('base64') }] })),
|
||||
createId: () => 'media-id',
|
||||
})
|
||||
|
||||
await runtime.generate({
|
||||
kind: 'hermes', request: { type: 'image', prompt: 'a geometric poster' }, workdir,
|
||||
})
|
||||
|
||||
assert.deepEqual(providerRequests, [['hermes', 'image']])
|
||||
})
|
||||
|
||||
test('maps ZGCI image requests to the advertised qwen-image model', async (t) => {
|
||||
const calls = []
|
||||
const { workdir, runtime } = fixture(t, async (url, init = {}) => {
|
||||
calls.push([String(url), init])
|
||||
return response(JSON.stringify({ data: [{ b64_json: PNG.toString('base64') }] }))
|
||||
}, {
|
||||
apiKey: 'stored-zgci-credential', baseUrl: 'https://hub.zgci.org/v1', model: 'glm',
|
||||
})
|
||||
|
||||
const result = await runtime.generate({
|
||||
kind: 'codex', request: { type: 'image', prompt: 'a geometric poster' }, workdir,
|
||||
})
|
||||
|
||||
assert.equal(result.filename, 'generated-image-media-id.png')
|
||||
assert.equal(calls[0][0], 'https://hub.zgci.org/v1/images/generations')
|
||||
assert.equal(JSON.parse(calls[0][1].body).model, 'qwen-image')
|
||||
})
|
||||
|
||||
test('writes binary audio output and uses the audio endpoint', async (t) => {
|
||||
const { workdir, runtime } = fixture(t, async (url) => {
|
||||
assert.equal(String(url), 'https://api.openai.com/v1/audio/speech')
|
||||
return response(MP3, { headers: { 'content-type': 'audio/mpeg' } })
|
||||
})
|
||||
const result = await runtime.generate({
|
||||
kind: 'codex', request: { type: 'audio', prompt: 'hello world' }, workdir,
|
||||
})
|
||||
|
||||
assert.equal(result.filename, 'generated-audio-media-id.mp3')
|
||||
assert.deepEqual(fs.readFileSync(path.join(workdir, '.meldwork-output', result.filename)), MP3)
|
||||
})
|
||||
|
||||
test('polls an async video job and downloads the completed media locally', async (t) => {
|
||||
const calls = []
|
||||
const { workdir, runtime } = fixture(t, async (url, init = {}) => {
|
||||
calls.push([String(url), init.method || 'GET'])
|
||||
if (String(url).endsWith('/videos')) {
|
||||
return response(JSON.stringify({ id: 'video-1', status: 'queued' }))
|
||||
}
|
||||
if (String(url).endsWith('/videos/video-1')) {
|
||||
return response(JSON.stringify({ status: 'completed', url: 'https://download.example/video.mp4' }))
|
||||
}
|
||||
return response(MP4, { headers: { 'content-type': 'video/mp4' } })
|
||||
})
|
||||
const result = await runtime.generate({
|
||||
kind: 'codex', request: { type: 'video', prompt: 'a sunset over water' }, workdir,
|
||||
})
|
||||
|
||||
assert.equal(result.filename, 'generated-video-media-id.mp4')
|
||||
assert.deepEqual(fs.readFileSync(path.join(workdir, '.meldwork-output', result.filename)), MP4)
|
||||
assert.deepEqual(calls.map(call => call[0]), [
|
||||
'https://api.openai.com/v1/videos',
|
||||
'https://api.openai.com/v1/videos/video-1',
|
||||
'https://download.example/video.mp4',
|
||||
])
|
||||
})
|
||||
|
||||
test('uses the legacy H3 submit, status, and content endpoints with secure provider credentials', async (t) => {
|
||||
const calls = []
|
||||
const { workdir, runtime } = fixture(t, async (url, init = {}) => {
|
||||
calls.push([String(url), init])
|
||||
if (String(url).endsWith('/v1/videogen')) {
|
||||
return response(JSON.stringify({ task_id: 'h3-task', status: 'queued', progress: 0 }))
|
||||
}
|
||||
if (String(url).endsWith('/v1/videogen/h3-task')) {
|
||||
return response(JSON.stringify({ task_id: 'h3-task', status: 'completed', progress: 100 }))
|
||||
}
|
||||
return response(MP4, { headers: { 'content-type': 'video/mp4' } })
|
||||
}, {
|
||||
apiKey: 'stored-h3-credential',
|
||||
baseUrl: 'https://helper.ihainan.me',
|
||||
model: 'minimax-h3',
|
||||
})
|
||||
|
||||
const result = await runtime.generate({
|
||||
kind: 'codex', request: { type: 'video', prompt: 'a sunlit city street' }, workdir,
|
||||
})
|
||||
|
||||
assert.equal(result.filename, 'generated-video-media-id.mp4')
|
||||
assert.deepEqual(fs.readFileSync(path.join(workdir, '.meldwork-output', result.filename)), MP4)
|
||||
assert.deepEqual(calls.map(([url]) => url), [
|
||||
'https://helper.ihainan.me/v1/videogen',
|
||||
'https://helper.ihainan.me/v1/videogen/h3-task',
|
||||
'https://helper.ihainan.me/v1/videogen/h3-task/content',
|
||||
])
|
||||
assert.deepEqual(JSON.parse(calls[0][1].body), {
|
||||
prompt: 'a sunlit city street', seconds: 4, steps: 20, size: '1280x720',
|
||||
})
|
||||
for (const [, init] of calls) {
|
||||
assert.equal(init.headers.Authorization, `Basic ${Buffer.from('stored-h3-credential').toString('base64')}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('uses the ZGCI OpenAI-compatible H3 workflow and advertised model', async (t) => {
|
||||
const calls = []
|
||||
const { workdir, runtime } = fixture(t, async (url, init = {}) => {
|
||||
calls.push([String(url), init])
|
||||
if (String(url).endsWith('/video/generations')) {
|
||||
return response(JSON.stringify({ id: 'zgci-task', status: 'queued' }))
|
||||
}
|
||||
if (String(url).endsWith('/videos/zgci-task')) {
|
||||
return response(JSON.stringify({
|
||||
id: 'zgci-task', status: 'completed',
|
||||
}))
|
||||
}
|
||||
return response(MP4, { headers: { 'content-type': 'video/mp4' } })
|
||||
}, {
|
||||
apiKey: 'stored-zgci-credential',
|
||||
baseUrl: 'https://hub.zgci.org/v1',
|
||||
model: 'glm',
|
||||
})
|
||||
|
||||
const result = await runtime.generate({
|
||||
kind: 'openclaw', request: { type: 'video', prompt: 'a calm city sunrise' }, workdir,
|
||||
})
|
||||
|
||||
assert.equal(result.filename, 'generated-video-media-id.mp4')
|
||||
assert.deepEqual(calls.map(([url]) => url), [
|
||||
'https://hub.zgci.org/v1/video/generations',
|
||||
'https://hub.zgci.org/v1/videos/zgci-task',
|
||||
'https://hub.zgci.org/v1/videos/zgci-task/content',
|
||||
])
|
||||
assert.deepEqual(JSON.parse(calls[0][1].body), {
|
||||
model: 'minimax-h3', prompt: 'a calm city sunrise', seconds: '4', size: '1280x720',
|
||||
})
|
||||
assert.equal(calls[2][1].headers.Authorization, 'Bearer stored-zgci-credential')
|
||||
})
|
||||
|
||||
test('maps ZGCI audio requests to CosyVoice', async (t) => {
|
||||
const calls = []
|
||||
const { workdir, runtime } = fixture(t, async (url, init = {}) => {
|
||||
calls.push([String(url), init])
|
||||
return response(Buffer.from('RIFFaudio'), { headers: { 'content-type': 'audio/wav' } })
|
||||
}, {
|
||||
apiKey: 'stored-zgci-credential',
|
||||
baseUrl: 'https://hub.zgci.org/v1',
|
||||
model: 'glm',
|
||||
})
|
||||
|
||||
const result = await runtime.generate({
|
||||
kind: 'hermes', request: { type: 'audio', prompt: '欢迎使用 Meldwork' }, workdir,
|
||||
})
|
||||
|
||||
assert.equal(result.filename, 'generated-audio-media-id.wav')
|
||||
assert.equal(calls[0][0], 'https://hub.zgci.org/v1/audio/speech')
|
||||
assert.deepEqual(JSON.parse(calls[0][1].body), {
|
||||
model: 'cosy-voice',
|
||||
input: '欢迎使用 Meldwork',
|
||||
voice: 'mm_Southern_Young_Man',
|
||||
response_format: 'wav',
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps provider errors secret-free and does not write a false attachment', async (t) => {
|
||||
const { workdir, runtime } = fixture(t, async () => (
|
||||
response('invalid token test-api-key', { status: 401, headers: { 'content-type': 'text/plain' } })
|
||||
))
|
||||
await assert.rejects(
|
||||
runtime.generate({
|
||||
kind: 'codex', request: { type: 'image', prompt: 'a test image' }, workdir,
|
||||
}),
|
||||
{ message: 'MEDIA_GENERATION_HTTP_401' },
|
||||
)
|
||||
assert.equal(fs.existsSync(path.join(workdir, '.meldwork-output', 'generated-image-media-id.png')), false)
|
||||
})
|
||||
|
||||
test('retries transient provider failures before writing generated media', async (t) => {
|
||||
let attempts = 0
|
||||
const { workdir, runtime } = fixture(t, async () => {
|
||||
attempts += 1
|
||||
if (attempts < 3) return response('temporarily unavailable', { status: 503 })
|
||||
return response(JSON.stringify({ data: [{ b64_json: PNG.toString('base64') }] }))
|
||||
})
|
||||
|
||||
const result = await runtime.generate({
|
||||
kind: 'hermes', request: { type: 'image', prompt: 'a calm lake' }, workdir,
|
||||
})
|
||||
|
||||
assert.equal(attempts, 3)
|
||||
assert.equal(result.filename, 'generated-image-media-id.png')
|
||||
assert.deepEqual(fs.readFileSync(path.join(workdir, '.meldwork-output', result.filename)), PNG)
|
||||
})
|
||||
|
||||
test('stops after three transient provider failures without writing an attachment', async (t) => {
|
||||
let attempts = 0
|
||||
const { workdir, runtime } = fixture(t, async () => {
|
||||
attempts += 1
|
||||
return response('still unavailable', { status: 503 })
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
runtime.generate({
|
||||
kind: 'hermes', request: { type: 'image', prompt: 'a calm lake' }, workdir,
|
||||
}),
|
||||
{ message: 'MEDIA_GENERATION_HTTP_503' },
|
||||
)
|
||||
assert.equal(attempts, 3)
|
||||
assert.equal(fs.existsSync(path.join(workdir, '.meldwork-output', 'generated-image-media-id.png')), false)
|
||||
})
|
||||
|
||||
test('falls back to another secure Provider when the first lacks the media model', async (t) => {
|
||||
const providerRequests = []
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-media-generation-'))
|
||||
const workdir = path.join(directory, 'workspace')
|
||||
fs.mkdirSync(workdir)
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const providers = [
|
||||
{
|
||||
apiKey: 'unsupported-key', baseUrl: 'https://hub.zgci.org/v1', model: 'glm',
|
||||
sourceKind: 'openclaw',
|
||||
},
|
||||
{
|
||||
apiKey: 'media-key', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4.1',
|
||||
sourceKind: 'opencodereview',
|
||||
},
|
||||
]
|
||||
const runtime = new MediaGenerationRuntime({
|
||||
getProvider: (_kind, _type, excludedKinds = []) => {
|
||||
providerRequests.push([...excludedKinds])
|
||||
return providers.find(provider => !excludedKinds.includes(provider.sourceKind))
|
||||
},
|
||||
fetchFn: async (_url, init = {}) => {
|
||||
if (init.headers.Authorization === 'Bearer unsupported-key') {
|
||||
return response(JSON.stringify({
|
||||
error: { code: 'model_not_found', message: 'No available channel for model minimax-h3' },
|
||||
}), { status: 503 })
|
||||
}
|
||||
if (String(_url).endsWith('/videos')) {
|
||||
return response(JSON.stringify({ id: 'video-1', status: 'completed' }))
|
||||
}
|
||||
return response(MP4, { headers: { 'content-type': 'video/mp4' } })
|
||||
},
|
||||
createId: () => 'media-id',
|
||||
pollIntervalMs: 0,
|
||||
})
|
||||
|
||||
const result = await runtime.generate({
|
||||
kind: 'hermes', request: { type: 'video', prompt: 'a calm lake' }, workdir,
|
||||
})
|
||||
|
||||
assert.deepEqual(providerRequests, [[], ['openclaw']])
|
||||
assert.equal(result.filename, 'generated-video-media-id.mp4')
|
||||
assert.deepEqual(fs.readFileSync(path.join(workdir, '.meldwork-output', result.filename)), MP4)
|
||||
})
|
||||
|
||||
test('reports a stable model-unavailable error after all secure Providers are exhausted', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-media-generation-'))
|
||||
const workdir = path.join(directory, 'workspace')
|
||||
fs.mkdirSync(workdir)
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const runtime = new MediaGenerationRuntime({
|
||||
getProvider: (_kind, _type, excludedKinds = []) => {
|
||||
if (excludedKinds.includes('openclaw')) {
|
||||
throw new Error('MEDIA_GENERATION_PROVIDER_UNAVAILABLE')
|
||||
}
|
||||
return {
|
||||
apiKey: 'unsupported-key', baseUrl: 'https://hub.zgci.org/v1', model: 'glm',
|
||||
sourceKind: 'openclaw',
|
||||
}
|
||||
},
|
||||
fetchFn: async () => response(JSON.stringify({
|
||||
error: { code: 'model_not_found', message: 'No available channel for model minimax-h3' },
|
||||
}), { status: 503 }),
|
||||
createId: () => 'media-id',
|
||||
pollIntervalMs: 0,
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
runtime.generate({
|
||||
kind: 'hermes', request: { type: 'video', prompt: 'a calm lake' }, workdir,
|
||||
}),
|
||||
{ code: 'MEDIA_GENERATION_MODEL_UNAVAILABLE' },
|
||||
)
|
||||
})
|
||||
|
||||
test('falls back to another secure Provider after transient retries are exhausted', async (t) => {
|
||||
const providerRequests = []
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-media-generation-'))
|
||||
const workdir = path.join(directory, 'workspace')
|
||||
fs.mkdirSync(workdir)
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const providers = [
|
||||
{
|
||||
apiKey: 'unavailable-key', baseUrl: 'https://hub.zgci.org/v1', model: 'glm',
|
||||
sourceKind: 'openclaw',
|
||||
},
|
||||
{
|
||||
apiKey: 'media-key', baseUrl: 'https://hub.zgci.org/v1', model: 'glm',
|
||||
sourceKind: 'opencodereview',
|
||||
},
|
||||
]
|
||||
let unavailableAttempts = 0
|
||||
const runtime = new MediaGenerationRuntime({
|
||||
getProvider: (_kind, _type, excludedKinds = []) => {
|
||||
providerRequests.push([...excludedKinds])
|
||||
const provider = providers.find(candidate => !excludedKinds.includes(candidate.sourceKind))
|
||||
if (!provider) throw new Error('MEDIA_GENERATION_PROVIDER_UNAVAILABLE')
|
||||
return provider
|
||||
},
|
||||
fetchFn: async (_url, init = {}) => {
|
||||
if (init.headers.Authorization === 'Bearer unavailable-key') {
|
||||
unavailableAttempts += 1
|
||||
return response('temporarily unavailable', { status: 503 })
|
||||
}
|
||||
if (String(_url).endsWith('/images/generations')) {
|
||||
return response(JSON.stringify({ data: [{ b64_json: PNG.toString('base64') }] }))
|
||||
}
|
||||
throw new Error(`Unexpected request: ${_url}`)
|
||||
},
|
||||
createId: () => 'media-id',
|
||||
pollIntervalMs: 0,
|
||||
})
|
||||
|
||||
const result = await runtime.generate({
|
||||
kind: 'hermes', request: { type: 'image', prompt: 'a calm lake' }, workdir,
|
||||
})
|
||||
|
||||
assert.equal(unavailableAttempts, 3)
|
||||
assert.deepEqual(providerRequests, [[], ['openclaw']])
|
||||
assert.equal(result.filename, 'generated-image-media-id.png')
|
||||
assert.deepEqual(fs.readFileSync(path.join(workdir, '.meldwork-output', result.filename)), PNG)
|
||||
})
|
||||
@@ -1,88 +0,0 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { resolveMediaProvider } = require('../../src/media/media-provider-resolution.cjs')
|
||||
|
||||
test('all Agents can fall back to one securely configured media Provider', () => {
|
||||
const statuses = {
|
||||
openclaw: {
|
||||
configured: true, baseUrl: 'https://hub.zgci.org/v1', model: 'glm',
|
||||
},
|
||||
}
|
||||
const provider = resolveMediaProvider({
|
||||
requestedKind: 'hermes',
|
||||
type: 'video',
|
||||
kinds: ['codex', 'hermes', 'openclaw'],
|
||||
statusFor: kind => statuses[kind] || { configured: false },
|
||||
credentialsFor: kind => kind === 'openclaw' ? { OPENAI_API_KEY: 'secure-key' } : {},
|
||||
})
|
||||
|
||||
assert.deepEqual(provider, {
|
||||
apiKey: 'secure-key',
|
||||
baseUrl: 'https://hub.zgci.org/v1',
|
||||
model: 'glm',
|
||||
sourceKind: 'openclaw',
|
||||
})
|
||||
})
|
||||
|
||||
test('video resolution prefers an H3-capable profile over the target chat profile', () => {
|
||||
const statuses = {
|
||||
hermes: { configured: true, baseUrl: 'https://api.openai.com/v1', model: 'gpt-4.1' },
|
||||
openclaw: { configured: true, baseUrl: 'https://hub.zgci.org/v1', model: 'glm' },
|
||||
}
|
||||
const provider = resolveMediaProvider({
|
||||
requestedKind: 'hermes',
|
||||
type: 'video',
|
||||
kinds: ['hermes', 'openclaw'],
|
||||
statusFor: kind => statuses[kind] || { configured: false },
|
||||
credentialsFor: kind => ({ OPENAI_API_KEY: `${kind}-key` }),
|
||||
})
|
||||
|
||||
assert.equal(provider.sourceKind, 'openclaw')
|
||||
assert.equal(provider.apiKey, 'openclaw-key')
|
||||
})
|
||||
|
||||
test('excludes a configured Provider after its media model is unavailable', () => {
|
||||
const statuses = {
|
||||
openclaw: { configured: true, baseUrl: 'https://hub.zgci.org/v1', model: 'glm' },
|
||||
opencodereview: { configured: true, baseUrl: 'https://hub.zgci.org/v1', model: 'glm' },
|
||||
}
|
||||
const provider = resolveMediaProvider({
|
||||
requestedKind: 'hermes',
|
||||
type: 'video',
|
||||
kinds: ['openclaw', 'opencodereview'],
|
||||
excludedKinds: ['openclaw'],
|
||||
statusFor: kind => statuses[kind] || { configured: false },
|
||||
credentialsFor: kind => ({ OPENAI_API_KEY: `${kind}-key` }),
|
||||
})
|
||||
|
||||
assert.equal(provider.sourceKind, 'opencodereview')
|
||||
assert.equal(provider.apiKey, 'opencodereview-key')
|
||||
})
|
||||
|
||||
test('uses a secure global ZGCI media fallback without requiring per-Agent provider binding', () => {
|
||||
const provider = resolveMediaProvider({
|
||||
requestedKind: 'codex',
|
||||
type: 'video',
|
||||
kinds: ['codex', 'hermes'],
|
||||
statusFor: () => ({ configured: false }),
|
||||
credentialsFor: () => ({}),
|
||||
fallbackProviders: [{
|
||||
kind: 'zgci-media',
|
||||
status: {
|
||||
configured: true,
|
||||
provider: 'ZGCI Media',
|
||||
baseUrl: 'https://hub.zgci.org/v1',
|
||||
model: 'glm',
|
||||
},
|
||||
credentials: { OPENAI_API_KEY: 'zgci-key' },
|
||||
}],
|
||||
})
|
||||
|
||||
assert.deepEqual(provider, {
|
||||
apiKey: 'zgci-key',
|
||||
baseUrl: 'https://hub.zgci.org/v1',
|
||||
model: 'glm',
|
||||
sourceKind: 'zgci-media',
|
||||
})
|
||||
})
|
||||
@@ -212,7 +212,7 @@ test('custom preset keeps accepting secure arbitrary and loopback endpoints', (t
|
||||
})
|
||||
|
||||
for (const kind of [
|
||||
'codex', 'hermes', 'openclaw', 'workbuddy', 'kimi', 'mimo',
|
||||
'codex', 'hermes', 'openclaw', 'workbuddy', 'pi', 'kimi', 'mimo',
|
||||
'claude', 'gemini', 'opencode', 'qwen', 'opencodereview',
|
||||
]) {
|
||||
test(`${kind} Provider endpoints are stored as API roots`, (t) => {
|
||||
|
||||
@@ -156,6 +156,35 @@ test('retries only transient categories and stops at the total attempt bound', (
|
||||
assert.equal(retryDecision(cancelled).action, 'fail')
|
||||
})
|
||||
|
||||
test('retries recoverable Agent protocol failures before the total attempt bound', () => {
|
||||
for (const code of [
|
||||
'LOCAL_RUN_COLLABORATION_RECEIPT_INVALID',
|
||||
'LOCAL_AGENT_EMPTY_RESPONSE',
|
||||
'LOCAL_AGENT_PROCESS_FAILED',
|
||||
'LOCAL_AGENT_EXITED',
|
||||
]) {
|
||||
const failure = normalizeFailure(Object.assign(new Error(code), { code }))
|
||||
assert.equal(failure.category, 'protocol')
|
||||
assert.equal(failure.retryability, 'retry')
|
||||
assert.equal(retryDecision(failure, { attempt: 1, maxAttempts: 4 }).action, 'retry')
|
||||
assert.equal(retryDecision(failure, { attempt: 4, maxAttempts: 4 }).action, 'fail')
|
||||
}
|
||||
})
|
||||
|
||||
test('retries a bounded Agent timeout while preserving the write-safety gate', () => {
|
||||
const timeout = normalizeFailure(Object.assign(
|
||||
new Error('LOCAL_AGENT_TIMEOUT'), { code: 'LOCAL_AGENT_TIMEOUT' },
|
||||
))
|
||||
assert.equal(timeout.category, 'timeout')
|
||||
assert.equal(timeout.retryability, 'retry')
|
||||
assert.equal(retryDecision(timeout, { attempt: 1, maxAttempts: 4 }).action, 'retry')
|
||||
assert.equal(retryDecision(timeout, {
|
||||
attempt: 1,
|
||||
maxAttempts: 4,
|
||||
safety: { sideEffectsPossible: true, idempotencyMode: 'none' },
|
||||
}).action, 'human_gate')
|
||||
})
|
||||
|
||||
test('requires approval before retrying side-effectful ambiguous failures', () => {
|
||||
const error = Object.assign(new Error('socket reset after dispatch'), {
|
||||
code: 'ECONNRESET',
|
||||
|
||||
@@ -641,6 +641,21 @@ test('recovers a legacy capsule round from its generated Agent run id', () => {
|
||||
assert.equal(explicit.round, 2)
|
||||
})
|
||||
|
||||
test('persists the orchestration phase alongside the round in a trace capsule', () => {
|
||||
const capsule = normalizeTraceCapsule({
|
||||
runId: 'run-phase',
|
||||
agentRunId: 'run-phase:2:codex:agent-1',
|
||||
round: 2,
|
||||
phase: 'challenge',
|
||||
status: 'completed',
|
||||
})
|
||||
assert.equal(capsule.round, 2)
|
||||
assert.equal(capsule.phase, 'challenge')
|
||||
assert.equal('phase' in normalizeTraceCapsule({
|
||||
runId: 'run-phase', agentRunId: 'run-phase:2:codex:agent-2', phase: 'not-a-phase',
|
||||
}), false)
|
||||
})
|
||||
|
||||
test('does not infer a legacy round from invalid explicit values or malformed Agent run ids', () => {
|
||||
for (const round of [true, '4', -1, 100001, null, undefined]) {
|
||||
const capsule = normalizeTraceCapsule({
|
||||
|
||||
@@ -1309,6 +1309,26 @@ function journalEntries(storagePath) {
|
||||
.map(line => JSON.parse(line))
|
||||
}
|
||||
|
||||
test('compacts a committed journal to a recoverable baseline', (t) => {
|
||||
const { storagePath } = fixture(t)
|
||||
const ledger = new RunLedger({
|
||||
storagePath,
|
||||
now: () => 1000,
|
||||
journalCompactionBytes: 1024,
|
||||
})
|
||||
ledger.checkpoint(runRecord('run-compact', 'group-compact'))
|
||||
ledger.finish('run-compact', 'completed')
|
||||
|
||||
const entries = journalEntries(storagePath)
|
||||
assert.deepEqual(entries.map(entry => entry.sequence), [1, 2])
|
||||
assert.deepEqual(entries.map(entry => entry.phase), ['prepare', 'commit'])
|
||||
fs.writeFileSync(storagePath, '{corrupt snapshot', 'utf8')
|
||||
|
||||
const restored = new RunLedger({ storagePath, now: () => 4000 })
|
||||
assert.equal(restored.loadError, null)
|
||||
assert.equal(restored.get('run-compact').status, 'completed')
|
||||
})
|
||||
|
||||
test('roundtrips sanitized bounded run and Agent snapshots', (t) => {
|
||||
const { storagePath } = fixture(t)
|
||||
const events = Array.from({ length: 85 }, (_, index) => ({
|
||||
|
||||
@@ -25,12 +25,30 @@ function deferred() {
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
test('exports one process-wide scheduler with bounded defaults and no run deadline', () => {
|
||||
test('exports one process-wide scheduler with no run deadline', () => {
|
||||
assert.equal(processRunScheduler instanceof RunScheduler, true)
|
||||
assert.deepEqual(processRunScheduler.snapshot().limits, { task: 2, workspace: 2, global: 4 })
|
||||
assert.equal(Object.hasOwn(processRunScheduler.snapshot(), 'timeoutMs'), false)
|
||||
})
|
||||
|
||||
test('default capacity is unlimited and starts every Agent in a collaboration batch', async (t) => {
|
||||
const scheduler = new RunScheduler()
|
||||
const leases = []
|
||||
t.after(() => leases.forEach(lease => lease.release()))
|
||||
|
||||
assert.deepEqual(scheduler.snapshot().limits, { task: null, workspace: null, global: null })
|
||||
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
void scheduler.acquire({
|
||||
taskId: 'shared-task',
|
||||
workspaceKey: 'shared-workspace',
|
||||
}).then(lease => { leases.push(lease) })
|
||||
}
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
assert.equal(scheduler.snapshot().active.global, 40)
|
||||
assert.equal(scheduler.snapshot().queued.length, 0)
|
||||
})
|
||||
|
||||
test('grants FIFO leases when shared capacity becomes available and records fake-clock queue timing', async () => {
|
||||
let now = 100
|
||||
const scheduler = new RunScheduler({
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const {
|
||||
discoverTestFiles,
|
||||
normalizeSuite,
|
||||
testSuiteForFile,
|
||||
} = require('../../scripts/test-discovery.cjs')
|
||||
|
||||
test('test discovery exposes stable domain suites', () => {
|
||||
assert.deepEqual([...normalizeSuite('agents')], ['agents'])
|
||||
assert.equal(testSuiteForFile('/tmp/project/test/workspace/example.test.cjs', '/tmp/project/test'), 'workspace')
|
||||
assert.throws(() => normalizeSuite('missing'), /Unknown desktop test suite/)
|
||||
})
|
||||
|
||||
test('test discovery filters recursively without changing the default set', () => {
|
||||
const all = discoverTestFiles()
|
||||
const agents = discoverTestFiles({ suite: 'agents' })
|
||||
|
||||
assert.ok(all.length > agents.length)
|
||||
assert.ok(agents.length > 0)
|
||||
assert.ok(agents.every((filename) => testSuiteForFile(filename) === 'agents'))
|
||||
})
|
||||
@@ -273,6 +273,10 @@ test('ready activates one fixed local workspace and loads the bundled frontend',
|
||||
'local-cloud-agent:provide-input',
|
||||
'local-cloud-agent:decide-permission',
|
||||
'local-cloud-agent:cancel',
|
||||
'local-cloud-agent-bridge:list',
|
||||
'local-cloud-agent-bridge:connect',
|
||||
'local-cloud-agent-bridge:refresh',
|
||||
'local-cloud-agent-bridge:delete',
|
||||
],
|
||||
)
|
||||
assert.equal([...harness.ipcHandlers.keys()].some(name => (
|
||||
@@ -919,6 +923,7 @@ test('attachment storage initialization failure does not block text chat startup
|
||||
['local-attachments:import', [{ name: 'x.png', mimeType: 'image/png', bytes: [1] }]],
|
||||
['local-attachments:preview', ['attachment-1']],
|
||||
['local-attachments:open', ['attachment-1']],
|
||||
['local-attachments:save', ['attachment-1']],
|
||||
['local-attachments:discard', [['attachment-1']]],
|
||||
]) {
|
||||
await assert.rejects(
|
||||
@@ -1721,14 +1726,66 @@ test('Skills IPC uses the installed-Agent gate and the shared local catalog', as
|
||||
assert.deepEqual(harness.skillCatalogInstances[0].listCalls, ['codex'])
|
||||
})
|
||||
|
||||
test('Cloud Agent catalog and Skills IPC preserve remote metadata without exposing paths', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-cloud-skills-ipc-'))
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const kind = 'cloud-0123456789abcdef01234567'
|
||||
class TestCloudAgentBridgeStore {
|
||||
refresh() { return Promise.resolve([]) }
|
||||
connectorEntries() { return [] }
|
||||
catalogEntries() {
|
||||
return [{
|
||||
kind, sourceKind: 'claude', label: 'Claude Code @ Research server',
|
||||
description: 'Remote Claude Code', version: '2.1.123', installed: true,
|
||||
installSupported: false, providerMode: 'connector', custom: false,
|
||||
connector: true, cloud: true, available: false, credentialState: 'missing',
|
||||
serverLabel: 'Research server',
|
||||
}]
|
||||
}
|
||||
skillsForInstance(instanceId) {
|
||||
return instanceId === kind
|
||||
? [{ namespace: 'claude', slug: 'research', name: 'Research' }]
|
||||
: null
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
const { harness } = loadMain(directory, {
|
||||
moduleMocks: {
|
||||
'./agents/cloud/cloud-agent-bridge.cjs': { CloudAgentBridgeStore: TestCloudAgentBridgeStore },
|
||||
},
|
||||
})
|
||||
await harness.ready()
|
||||
|
||||
const catalog = await harness.ipcHandlers.get('local-agent-installer:catalog')(harness.event())
|
||||
const cloudAgent = catalog.agents.find(agent => agent.kind === kind)
|
||||
assert.equal(cloudAgent.sourceKind, 'claude')
|
||||
assert.equal(cloudAgent.version, '2.1.123')
|
||||
assert.equal(cloudAgent.credentialState, 'missing')
|
||||
assert.equal(cloudAgent.available, false)
|
||||
assert.equal(cloudAgent.custom, false)
|
||||
assert.equal('executable' in cloudAgent, false)
|
||||
|
||||
assert.deepEqual(
|
||||
await harness.ipcHandlers.get('local-agent-installer:skills')(harness.event(), kind),
|
||||
{
|
||||
supported: true,
|
||||
total: 1,
|
||||
limit: 64,
|
||||
skills: [{ targetKind: kind, namespace: 'claude', slug: 'research', name: 'Research' }],
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('attachment IPC returns bounded metadata, opens private files, and never exposes paths', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-image-ipc-'))
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const first = path.join(directory, 'diagram.png')
|
||||
const second = path.join(directory, 'flow.png')
|
||||
const third = path.join(directory, 'overflow.png')
|
||||
const saved = path.join(directory, 'saved.png')
|
||||
const { harness } = loadMain(directory, {
|
||||
dialogResult: { canceled: false, filePaths: [first, second, third] },
|
||||
saveDialogResult: { canceled: false, filePath: saved },
|
||||
})
|
||||
await harness.ready()
|
||||
|
||||
@@ -1784,6 +1841,15 @@ test('attachment IPC returns bounded metadata, opens private files, and never ex
|
||||
assert.deepEqual(harness.openPathCalls, [
|
||||
path.join(directory, 'attachments', picked.attachments[0].id, 'image.png'),
|
||||
])
|
||||
const savedResult = await harness.ipcHandlers.get('local-attachments:save')(
|
||||
harness.event(), picked.attachments[0].id,
|
||||
)
|
||||
assert.deepEqual(savedResult, { saved: true })
|
||||
assert.deepEqual(harness.dialogCalls[1][1], {
|
||||
defaultPath: 'diagram.png',
|
||||
showOverwriteConfirmation: true,
|
||||
})
|
||||
assert.deepEqual(fs.readFileSync(saved), pngHeader())
|
||||
assert.equal(JSON.stringify(opened).includes(directory), false)
|
||||
})
|
||||
|
||||
|
||||
@@ -62,12 +62,17 @@ test('local preload exposes the local-only Meldwork API and narrow Provider meth
|
||||
assert.equal(Object.isFrozen(api.localAgentConnector), true)
|
||||
assert.equal(Object.isFrozen(api.localSkillTrust), true)
|
||||
assert.equal(Object.isFrozen(api.cloudAgent), true)
|
||||
assert.equal(Object.isFrozen(api.cloudAgentBridge), true)
|
||||
assert.equal(Object.isFrozen(api.localOutcome), true)
|
||||
assert.deepEqual(Object.keys(api.localOutcome), ['recordAdoption'])
|
||||
assert.deepEqual(
|
||||
Object.keys(api.cloudAgent).sort(),
|
||||
['cancel', 'decidePermission', 'provideInput'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
Object.keys(api.cloudAgentBridge).sort(),
|
||||
['connect', 'delete', 'list', 'refresh'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
Object.keys(api.localWorkspace).sort(),
|
||||
[
|
||||
@@ -413,7 +418,7 @@ test('local preload exposes attachment import and open without filesystem read o
|
||||
assert.equal(Object.isFrozen(api.localAttachments), true)
|
||||
assert.deepEqual(
|
||||
Object.keys(api.localAttachments).sort(),
|
||||
['discard', 'importAttachment', 'open', 'pickAttachments', 'preview'],
|
||||
['discard', 'importAttachment', 'open', 'pickAttachments', 'preview', 'save'],
|
||||
)
|
||||
assert.equal('read' in api.localAttachments, false)
|
||||
assert.equal('resolve' in api.localAttachments, false)
|
||||
@@ -435,6 +440,7 @@ test('local preload exposes attachment import and open without filesystem read o
|
||||
archiveBytes[0] = 0
|
||||
await api.localAttachments.preview('attachment-1')
|
||||
await api.localAttachments.open('attachment-1')
|
||||
await api.localAttachments.save('attachment-1')
|
||||
await api.localAttachments.discard(['attachment-1'])
|
||||
assert.deepEqual(invocations, [
|
||||
{ channel: 'local-attachments:pick', args: [2] },
|
||||
@@ -460,6 +466,7 @@ test('local preload exposes attachment import and open without filesystem read o
|
||||
},
|
||||
{ channel: 'local-attachments:preview', args: ['attachment-1'] },
|
||||
{ channel: 'local-attachments:open', args: ['attachment-1'] },
|
||||
{ channel: 'local-attachments:save', args: ['attachment-1'] },
|
||||
{ channel: 'local-attachments:discard', args: [['attachment-1']] },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -44,6 +44,9 @@ function fixture() {
|
||||
createRunId: () => `run-${++runId}`,
|
||||
retryBaseDelayMs: 1,
|
||||
retryMaxDelayMs: 4,
|
||||
// Gate-focused tests opt out explicitly; production defaults to yolo.
|
||||
defaultYolo: false,
|
||||
naturalAgentResponses: false,
|
||||
}
|
||||
Object.defineProperty(options, 'runAgent', {
|
||||
enumerable: true,
|
||||
|
||||
@@ -10,7 +10,7 @@ const PROVIDER_METADATA = Object.freeze({
|
||||
model: 'example-model',
|
||||
})
|
||||
const PROVIDER_AGENT_KINDS = Object.freeze([
|
||||
'codex', 'hermes', 'openclaw', 'workbuddy', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen',
|
||||
'codex', 'hermes', 'openclaw', 'workbuddy', 'pi', 'kimi', 'mimo', 'claude', 'gemini', 'opencode', 'qwen',
|
||||
'opencodereview',
|
||||
])
|
||||
|
||||
@@ -38,6 +38,10 @@ const LOCAL_IPC_CHANNELS = Object.freeze([
|
||||
'local-cloud-agent:provide-input',
|
||||
'local-cloud-agent:decide-permission',
|
||||
'local-cloud-agent:cancel',
|
||||
'local-cloud-agent-bridge:list',
|
||||
'local-cloud-agent-bridge:connect',
|
||||
'local-cloud-agent-bridge:refresh',
|
||||
'local-cloud-agent-bridge:delete',
|
||||
'local-outcome:record-adoption',
|
||||
'local-workspace:pick-directory',
|
||||
'local-workspace:default-directory',
|
||||
@@ -69,6 +73,7 @@ const LOCAL_IPC_CHANNELS = Object.freeze([
|
||||
'local-attachments:import',
|
||||
'local-attachments:preview',
|
||||
'local-attachments:open',
|
||||
'local-attachments:save',
|
||||
'local-attachments:discard',
|
||||
'local-agent-provider:status',
|
||||
'local-agent-provider:probe',
|
||||
@@ -742,6 +747,10 @@ function loadMain(userData, options = {}) {
|
||||
dialogCalls.push(args)
|
||||
return options.dialogResult || { canceled: true, filePaths: [] }
|
||||
},
|
||||
showSaveDialog: async (...args) => {
|
||||
dialogCalls.push(args)
|
||||
return options.saveDialogResult || { canceled: true, filePath: '' }
|
||||
},
|
||||
showMessageBox: async (...args) => {
|
||||
dialogCalls.push(args)
|
||||
return options.messageBoxResult || { response: 1 }
|
||||
|
||||
@@ -76,6 +76,41 @@ test('refresh starts credential checks concurrently and reads current runtime st
|
||||
assert.deepEqual(events, ['emit', 'snapshot'])
|
||||
})
|
||||
|
||||
test('concurrent refresh requests run in order instead of racing Agent snapshots', async () => {
|
||||
const first = deferred()
|
||||
const second = deferred()
|
||||
let calls = 0
|
||||
let active = 0
|
||||
let maxActive = 0
|
||||
const { agents, catalog } = fixture({
|
||||
detectAgents: async () => {
|
||||
calls += 1
|
||||
active += 1
|
||||
maxActive = Math.max(maxActive, active)
|
||||
const result = await (calls === 1 ? first.promise : second.promise)
|
||||
active -= 1
|
||||
return result
|
||||
},
|
||||
})
|
||||
|
||||
const initial = catalog.refresh()
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
const queued = catalog.refresh()
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.equal(calls, 1)
|
||||
assert.equal(maxActive, 1)
|
||||
|
||||
first.resolve([{ kind: 'hermes', compatibilityState: 'compatible' }])
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.equal(calls, 2)
|
||||
assert.equal(maxActive, 1)
|
||||
|
||||
second.resolve([{ kind: 'codex', compatibilityState: 'compatible' }])
|
||||
await Promise.all([initial, queued])
|
||||
assert.deepEqual(agents().map(agent => agent.kind), ['codex'])
|
||||
assert.equal(active, 0)
|
||||
})
|
||||
|
||||
test('incompatible Agents remain installed but unavailable with ready credentials', async () => {
|
||||
const { agents, catalog, events } = fixture({
|
||||
detectAgents: async () => [{
|
||||
@@ -109,6 +144,33 @@ test('incompatible Agents remain installed but unavailable with ready credential
|
||||
assert.deepEqual(events, ['emit', 'snapshot'])
|
||||
})
|
||||
|
||||
test('locally detected compatible Agents remain invocable when native configuration cannot be inspected', async () => {
|
||||
const { agents, catalog } = fixture({
|
||||
detectAgents: async () => [{
|
||||
kind: 'pi', executable: '/tmp/pi', compatibilityState: 'compatible',
|
||||
}, {
|
||||
kind: 'opencode', executable: '/tmp/opencode', compatibilityState: 'compatible',
|
||||
}],
|
||||
})
|
||||
|
||||
await catalog.refresh()
|
||||
|
||||
assert.deepEqual(agents().map(agent => ({
|
||||
kind: agent.kind,
|
||||
configured: agent.configured,
|
||||
authenticated: agent.authenticated,
|
||||
invocable: agent.invocable,
|
||||
available: agent.available,
|
||||
availabilitySource: agent.availabilitySource,
|
||||
})), [{
|
||||
kind: 'pi', configured: true, authenticated: true, invocable: true,
|
||||
available: true, availabilitySource: 'local-cli',
|
||||
}, {
|
||||
kind: 'opencode', configured: true, authenticated: true, invocable: true,
|
||||
available: true, availabilitySource: 'local-cli',
|
||||
}])
|
||||
})
|
||||
|
||||
test('shared Provider readiness cannot override a recorded runtime auth failure', async () => {
|
||||
const { agents, catalog, events, state } = fixture({
|
||||
detectAgents: async () => [{
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const support = require('../../src/workspace/local-workspace-auto-runner-support.cjs')
|
||||
const { LocalWorkspaceAutoRunner } = require('../../src/workspace/local-workspace-auto-runner.cjs')
|
||||
|
||||
test('auto runner support keeps authentication failures sanitized and non-retryable', () => {
|
||||
const original = Object.assign(new Error('Forbidden'), { statusCode: 403 })
|
||||
const sanitized = support.sanitizedAuthenticationError(original)
|
||||
|
||||
assert.equal(sanitized.code, 'LOCAL_AGENT_AUTH_REQUIRED')
|
||||
assert.equal(sanitized.statusCode, 403)
|
||||
assert.equal(sanitized.failure.retryable, false)
|
||||
assert.match(sanitized.message, /authentication failed/)
|
||||
})
|
||||
|
||||
test('auto runner support abortable delay settles immediately when stopped', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await assert.rejects(
|
||||
support.abortableDelay(10, controller.signal),
|
||||
{ message: 'LOCAL_AGENT_EXECUTION_STOPPED' },
|
||||
)
|
||||
})
|
||||
|
||||
test('auto runner exposes extracted v4 outcome helpers through the runner prototype', () => {
|
||||
const runner = new LocalWorkspaceAutoRunner({
|
||||
state: () => ({ messages: [] }),
|
||||
beginRun: () => {},
|
||||
resolveAttachments: () => {},
|
||||
validateSkillSelections: () => {},
|
||||
validateKnowledgeBaseSelections: () => {},
|
||||
invokeAgent: () => {},
|
||||
resetAgentSession: () => {},
|
||||
refreshAgents: () => {},
|
||||
consumeAgentControl: () => {},
|
||||
markRuntimeCredential: () => {},
|
||||
agentLabel: kind => kind,
|
||||
recordAgentFailure: () => {},
|
||||
recordAgentInterruption: () => {},
|
||||
addMessage: () => {},
|
||||
emitChanged: () => {},
|
||||
finishRun: () => {},
|
||||
checkpointRun: () => true,
|
||||
})
|
||||
const records = [
|
||||
{ receipt: { phase: 'proposal', agentKind: 'openclaw', receiptId: 'old' } },
|
||||
{ receipt: { phase: 'proposal', agentKind: 'openclaw', receiptId: 'new' } },
|
||||
]
|
||||
|
||||
assert.equal(runner.v4LatestReceipt(records, 'proposal', 'openclaw').receipt.receiptId, 'new')
|
||||
assert.equal(typeof runner.v4Acceptance, 'function')
|
||||
})
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,9 @@ const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { getEventListeners } = require('node:events')
|
||||
const { manifestFor } = require('../../src/agents/cloud/cloud-agent-bridge.cjs')
|
||||
const { AgentConnectorInstanceStore } = require('../../src/agents/connectors/agent-connector-instance-store.cjs')
|
||||
const { LocalAgentConnectors } = require('../../src/agents/connectors/agent-connector-local.cjs')
|
||||
const { createAgentConnectorManifest } = require('../../src/agents/connectors/agent-connector-manifest.cjs')
|
||||
const { AgentConnectorRegistry } = require('../../src/agents/connectors/agent-connector-registry.cjs')
|
||||
const { AgentConnectorRuntime } = require('../../src/agents/connectors/agent-connector-runtime.cjs')
|
||||
@@ -14,6 +17,14 @@ const { RunScheduler } = require('../../src/runs/run-scheduler.cjs')
|
||||
const { deferred, fixture } = require('../support/local-workspace-test-helpers.cjs')
|
||||
const { executable } = require('../support/cli-adapters-test-helpers.cjs')
|
||||
|
||||
function testSafeStorage() {
|
||||
return {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: value => Buffer.from(`sealed:${value}`, 'utf8'),
|
||||
decryptString: value => Buffer.from(value).toString('utf8').slice('sealed:'.length),
|
||||
}
|
||||
}
|
||||
|
||||
function inputConnectorRuntime(handler) {
|
||||
const manifest = createAgentConnectorManifest({
|
||||
connectorId: 'external.input-agent',
|
||||
@@ -102,6 +113,114 @@ process.stdout.write(JSON.stringify({ type: 'turn.completed' }) + '\\n')
|
||||
assert.doesNotMatch(publicEvents.map(event => event.delta).join(''), new RegExp(secret))
|
||||
})
|
||||
|
||||
test('Cloud Bridge Agents can create direct and group chats through the Connector runtime', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const record = {
|
||||
bridgeId: 'cloud-bridge-0123456789abcdef01234567',
|
||||
transport: 'ssh-tunnel',
|
||||
address: '10.1.132.21',
|
||||
endpoint: 'http://127.0.0.1:45678',
|
||||
label: 'Cloud server',
|
||||
available: true,
|
||||
}
|
||||
const agents = ['codex', 'hermes'].map(id => ({
|
||||
id,
|
||||
sourceKind: id,
|
||||
label: id === 'codex' ? 'Codex' : 'Hermes',
|
||||
version: '1.0.0',
|
||||
description: '',
|
||||
available: true,
|
||||
credentialState: 'ready',
|
||||
domains: ['general'],
|
||||
inputTypes: ['text'],
|
||||
permissionModes: ['read-only'],
|
||||
session: { supported: true, resume: true, cancel: true, checkpoint: false },
|
||||
}))
|
||||
const entries = agents.map((agent) => {
|
||||
const manifest = manifestFor(record, agent)
|
||||
return {
|
||||
record,
|
||||
agent,
|
||||
manifest,
|
||||
instance: {
|
||||
instanceId: manifest.connectorId,
|
||||
connectorId: manifest.connectorId,
|
||||
connectorVersion: manifest.connectorVersion,
|
||||
upstreamVersion: '1.0.0',
|
||||
label: manifest.label,
|
||||
credentialRef: null,
|
||||
manifestId: manifest.manifestId,
|
||||
bridgeId: record.bridgeId,
|
||||
agentId: agent.id,
|
||||
},
|
||||
}
|
||||
})
|
||||
const cloudRuns = []
|
||||
const cloudBridges = {
|
||||
connectorEntries: () => entries,
|
||||
bridgeForInstance: instanceId => entries.find(entry => entry.instance.instanceId === instanceId) || null,
|
||||
run: async (input) => {
|
||||
cloudRuns.push(input)
|
||||
const heartbeat = setInterval(() => input.onActivity?.(), 5)
|
||||
await new Promise(resolve => setTimeout(resolve, 65))
|
||||
clearInterval(heartbeat)
|
||||
return { text: `${input.agentId} cloud reply`, outcome: 'completed' }
|
||||
},
|
||||
}
|
||||
const connectors = new LocalAgentConnectors({
|
||||
manifestDirectory: path.join(directory, 'agent-connectors'),
|
||||
instanceStore: new AgentConnectorInstanceStore({
|
||||
instanceStoragePath: path.join(directory, 'connector-instances.json'),
|
||||
credentialStoragePath: path.join(directory, 'private', 'connector-credentials.json'),
|
||||
safeStorage: testSafeStorage(),
|
||||
}),
|
||||
runAgent: async () => { throw new Error('LOCAL_RUNNER_MUST_NOT_RUN') },
|
||||
cloudBridges,
|
||||
})
|
||||
options.detectAgents = async () => connectors.refresh([])
|
||||
options.connectorRuntime = connectors
|
||||
options.runAgent = async () => { throw new Error('LOCAL_RUNNER_MUST_NOT_RUN') }
|
||||
options.runAgentTimeoutMs = 20
|
||||
options.runAbortGraceMs = 20
|
||||
const workspace = new LocalWorkspace(options)
|
||||
const snapshot = await workspace.refreshAgents()
|
||||
const codexKind = entries.find(entry => entry.agent.id === 'codex').instance.instanceId
|
||||
const hermesKind = entries.find(entry => entry.agent.id === 'hermes').instance.instanceId
|
||||
|
||||
assert.deepEqual(snapshot.agents.map(agent => agent.kind).sort(), [codexKind, hermesKind].sort())
|
||||
const direct = workspace.createGroup({
|
||||
conversationType: 'direct', directAgentKind: codexKind, workdir: directory,
|
||||
})
|
||||
await workspace.sendMessage({ groupId: direct.id, text: 'Direct cloud request' })
|
||||
|
||||
const group = workspace.createGroup({
|
||||
name: 'Cloud collaboration', agentKinds: [codexKind, hermesKind], workdir: directory,
|
||||
})
|
||||
await workspace.sendMessage({
|
||||
groupId: group.id,
|
||||
text: 'Group cloud request',
|
||||
targetKinds: [codexKind, hermesKind],
|
||||
})
|
||||
|
||||
assert.deepEqual(
|
||||
cloudRuns.map(run => run.agentId),
|
||||
['codex', 'codex', 'hermes'],
|
||||
JSON.stringify(workspace.snapshot().messages),
|
||||
)
|
||||
assert.equal(cloudRuns.every(run => run.permissionMode === 'read-only'), true)
|
||||
assert.equal(cloudRuns.every(run => typeof run.onActivity === 'function'), true)
|
||||
assert.deepEqual(
|
||||
workspace.snapshot().messages.filter(message => message.role === 'agent')
|
||||
.map(message => [message.groupId, message.agentKind, message.content]),
|
||||
[
|
||||
[direct.id, codexKind, 'codex cloud reply'],
|
||||
[group.id, codexKind, 'codex cloud reply'],
|
||||
[group.id, hermesKind, 'hermes cloud reply'],
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test('Harness streams per-Agent events, persists a compact trace, and hands evidence to the next Agent', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
@@ -1085,6 +1204,7 @@ test('terminal Agent traces hand compact partial evidence to the next Agent only
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: 'Terminal evidence', agentKinds: ['codex', 'hermes'], workdir: directory,
|
||||
allowWrite: false,
|
||||
})
|
||||
workspace.addMessage(
|
||||
group.id,
|
||||
@@ -1588,6 +1708,7 @@ test('Harness refuses to persist a non-terminal Agent acknowledgement as a resul
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: 'Non-terminal result', agentKinds: ['codex'], workdir: directory,
|
||||
allowWrite: false,
|
||||
})
|
||||
|
||||
await workspace.sendMessage({
|
||||
@@ -1645,6 +1766,7 @@ test('per-Agent watchdog persists a timeout trace and continues the automatic ro
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: 'Agent watchdog', agentKinds: ['codex', 'hermes'], workdir: directory,
|
||||
allowWrite: false,
|
||||
})
|
||||
|
||||
await workspace.sendMessage({
|
||||
@@ -1657,13 +1779,19 @@ test('per-Agent watchdog persists a timeout trace and continues the automatic ro
|
||||
await lateCallbacksDone.promise
|
||||
|
||||
assert.equal(timedOutSignal.aborted, true)
|
||||
assert.deepEqual(calls.map(call => call.agent.kind), ['codex', 'hermes'])
|
||||
assert.deepEqual(calls.map(call => call.agent.kind), [
|
||||
'codex', 'hermes',
|
||||
])
|
||||
const failure = workspace.snapshot().messages.find(message => (
|
||||
message.agentKind === 'codex' && message.system?.key === 'system.agentCallFailed'
|
||||
))
|
||||
assert.equal(failure.system.params.reason, 'LOCAL_AGENT_TIMEOUT')
|
||||
assert.equal(failure.trace.status, 'timeout')
|
||||
assert.equal(finished[0].status, 'round-limit')
|
||||
assert.equal(finished[0].status, 'partial')
|
||||
assert.deepEqual(workspace.getGroup(group.id).agentKinds, ['codex', 'hermes'])
|
||||
assert.equal(workspace.snapshot().messages.some(message => (
|
||||
message.agentKind === 'codex' && message.system?.key === 'system.agentRemovedAfterFailure'
|
||||
)), false)
|
||||
assert.equal(events.some(event => (
|
||||
event.agentKind === 'codex' && event.type === 'status' && event.status === 'timeout'
|
||||
)), true)
|
||||
@@ -1671,6 +1799,169 @@ test('per-Agent watchdog persists a timeout trace and continues the automatic ro
|
||||
assert.equal(workspace.state.sessions[workspace.sessionKey(group.id, 'codex')], undefined)
|
||||
})
|
||||
|
||||
test('a real runtime event keeps a quiet long-running Agent alive', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
options.runAgentTimeoutMs = 8
|
||||
options.runAgentToolTimeoutMs = 50
|
||||
options.runAbortGraceMs = 20
|
||||
options.runAgent = async (agent, prompt, workdir, runOptions) => {
|
||||
if (agent.kind !== 'codex') {
|
||||
return {
|
||||
text: `${agent.kind} completed`,
|
||||
sessionRef: `${agent.kind}-session`,
|
||||
}
|
||||
}
|
||||
runOptions.onEvent({
|
||||
id: 'tool-before-quiet',
|
||||
type: 'tool_start',
|
||||
title: 'search',
|
||||
status: 'running',
|
||||
})
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
return {
|
||||
text: `${agent.kind} completed after a quiet tool call`,
|
||||
sessionRef: `${agent.kind}-session`,
|
||||
}
|
||||
}
|
||||
const finished = []
|
||||
const workspace = new LocalWorkspace(options)
|
||||
workspace.on('run-finished', result => finished.push(result))
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: 'Quiet runtime event', agentKinds: ['codex', 'hermes'], workdir: directory,
|
||||
allowWrite: false,
|
||||
})
|
||||
|
||||
await workspace.sendMessage({
|
||||
groupId: group.id,
|
||||
text: 'Complete after a long-running tool call',
|
||||
mode: 'auto',
|
||||
maxRounds: 1,
|
||||
})
|
||||
await workspace.activeRuns.get(group.id).promise
|
||||
|
||||
assert.equal(finished[0].status, 'round-limit')
|
||||
assert.equal(workspace.snapshot().messages.some(message => (
|
||||
message.system?.key === 'system.agentCallFailed'
|
||||
&& message.agentKind === 'codex'
|
||||
)), false)
|
||||
})
|
||||
|
||||
test('an unmatched tool start cannot keep an automatic discussion running forever', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
options.runAgentTimeoutMs = 8
|
||||
options.runAgentToolTimeoutMs = 20
|
||||
options.runAbortGraceMs = 20
|
||||
options.runSilenceWarningMs = 1000
|
||||
options.runAgent = async (agent, prompt, workdir, runOptions) => {
|
||||
if (agent.kind !== 'codex') {
|
||||
return {
|
||||
text: `${agent.kind} completed`,
|
||||
sessionRef: `${agent.kind}-session`,
|
||||
}
|
||||
}
|
||||
runOptions.onEvent({
|
||||
id: 'tool-without-terminal-event',
|
||||
type: 'tool_start',
|
||||
title: 'search',
|
||||
status: 'running',
|
||||
})
|
||||
return new Promise((resolve, reject) => {
|
||||
const heartbeat = setInterval(() => runOptions.onActivity(), 1)
|
||||
const abort = () => {
|
||||
clearInterval(heartbeat)
|
||||
reject(new Error('LOCAL_AGENT_EXECUTION_STOPPED'))
|
||||
}
|
||||
if (runOptions.signal.aborted) abort()
|
||||
else runOptions.signal.addEventListener('abort', abort, { once: true })
|
||||
})
|
||||
}
|
||||
const finished = []
|
||||
const workspace = new LocalWorkspace(options)
|
||||
workspace.on('run-finished', result => finished.push(result))
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: 'Unmatched tool lifecycle',
|
||||
agentKinds: ['codex', 'hermes'],
|
||||
workdir: directory,
|
||||
allowWrite: false,
|
||||
})
|
||||
workspace.addMessage(group.id, 'user', 'Do not remain running after a tool stalls')
|
||||
|
||||
workspace.startAuto({ groupId: group.id, maxRounds: 1 })
|
||||
const runPromise = workspace.activeRuns.get(group.id).promise
|
||||
const settled = await Promise.race([
|
||||
runPromise.then(() => true),
|
||||
new Promise(resolve => setTimeout(() => resolve(false), 100)),
|
||||
])
|
||||
if (!settled) await workspace.stopAll()
|
||||
|
||||
assert.equal(settled, true)
|
||||
assert.equal(finished.length, 1)
|
||||
assert.equal(finished[0].status, 'partial')
|
||||
const failure = workspace.snapshot().messages.find(message => (
|
||||
message.agentKind === 'codex' && message.system?.key === 'system.agentCallFailed'
|
||||
))
|
||||
assert.equal(failure?.system?.params?.reason, 'LOCAL_AGENT_TIMEOUT')
|
||||
assert.equal(failure?.trace?.status, 'timeout')
|
||||
})
|
||||
|
||||
test('automatic discussion hard-stops a heartbeat-only Agent and continues later rounds', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
options.runAgentTimeoutMs = 20
|
||||
options.runAbortGraceMs = 20
|
||||
options.runSilenceWarningMs = 1000
|
||||
options.retrySleep = async () => {}
|
||||
options.runAgent = async (agent, prompt, workdir, runOptions) => {
|
||||
calls.push({ agent, prompt, workdir, runOptions })
|
||||
if (agent.kind === 'codex') {
|
||||
await new Promise((resolve, reject) => {
|
||||
const abort = () => reject(new Error('LOCAL_AGENT_EXECUTION_STOPPED'))
|
||||
if (runOptions.signal.aborted) abort()
|
||||
else runOptions.signal.addEventListener('abort', abort, { once: true })
|
||||
const heartbeat = setInterval(() => runOptions.onActivity(), 1)
|
||||
runOptions.signal.addEventListener('abort', () => clearInterval(heartbeat), { once: true })
|
||||
})
|
||||
}
|
||||
return {
|
||||
text: `${agent.kind} continues`,
|
||||
sessionRef: runOptions.sessionRef || `${agent.kind}-session`,
|
||||
}
|
||||
}
|
||||
const finished = []
|
||||
const workspace = new LocalWorkspace(options)
|
||||
workspace.on('run-finished', result => finished.push(result))
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: 'Heartbeat-only Agent isolation',
|
||||
agentKinds: ['codex', 'hermes', 'workbuddy'],
|
||||
workdir: directory,
|
||||
allowWrite: false,
|
||||
})
|
||||
workspace.addMessage(group.id, 'user', 'Continue for two rounds after one Agent stalls')
|
||||
|
||||
workspace.startAuto({ groupId: group.id, maxRounds: 2 })
|
||||
await workspace.activeRuns.get(group.id).promise
|
||||
|
||||
assert.deepEqual(calls.map(call => call.agent.kind), [
|
||||
'codex',
|
||||
'hermes', 'workbuddy', 'hermes', 'workbuddy',
|
||||
])
|
||||
assert.equal(
|
||||
workspace.snapshot().messages.filter(message => message.role === 'agent').length,
|
||||
4,
|
||||
)
|
||||
assert.deepEqual(workspace.getGroup(group.id).agentKinds, ['codex', 'hermes', 'workbuddy'])
|
||||
assert.equal(finished.length, 1)
|
||||
assert.equal(finished[0].status, 'partial')
|
||||
assert.equal(workspace.snapshot().messages.some(message => (
|
||||
message.agentKind === 'codex' && message.system?.key === 'system.agentCallFailed'
|
||||
)), true)
|
||||
})
|
||||
|
||||
test('manual Agent watchdog finishes the run as timeout and removes the parent abort listener', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
@@ -1691,6 +1982,7 @@ test('manual Agent watchdog finishes the run as timeout and removes the parent a
|
||||
const group = workspace.createGroup({
|
||||
name: 'Manual watchdog', agentKinds: ['codex'], workdir: directory,
|
||||
conversationType: 'direct', directAgentKind: 'codex',
|
||||
allowWrite: false,
|
||||
})
|
||||
|
||||
const send = workspace.sendMessage({ groupId: group.id, text: 'Do not wait forever' })
|
||||
@@ -1735,6 +2027,38 @@ test('a slow Agent that keeps reporting progress is not timed out by elapsed wal
|
||||
assert.equal(reply.trace.status, 'completed')
|
||||
})
|
||||
|
||||
test('cloud Agent activity releases the initial watchdog deadline', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
const cloudKind = 'cloud-0123456789abcdef01234567'
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
options.runAgentTimeoutMs = 20
|
||||
options.runSilenceWarningMs = 1000
|
||||
options.detectAgents = async () => [{
|
||||
kind: cloudKind, name: 'Hermes @ Cloud server', version: '1', available: true,
|
||||
compatibilityState: 'compatible', cloud: true,
|
||||
}]
|
||||
options.runAgent = async (_agent, _prompt, _workdir, runOptions) => {
|
||||
const heartbeat = setInterval(() => runOptions.onActivity(), 5)
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 65))
|
||||
return { text: 'Cloud reply arrived', sessionRef: 'cloud-session' }
|
||||
} finally {
|
||||
clearInterval(heartbeat)
|
||||
}
|
||||
}
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: 'Cloud watchdog', agentKinds: [cloudKind], workdir: directory,
|
||||
})
|
||||
|
||||
await workspace.sendMessage({ groupId: group.id, text: 'Wait for the cloud reply' })
|
||||
|
||||
const reply = workspace.snapshot().messages.find(message => message.role === 'agent')
|
||||
assert.equal(reply.content, 'Cloud reply arrived')
|
||||
assert.equal(reply.trace.status, 'completed')
|
||||
})
|
||||
|
||||
test('terminal Agent states persist conclusion text already streamed through answer deltas', async (t) => {
|
||||
const scenarios = [
|
||||
{
|
||||
@@ -1789,6 +2113,7 @@ test('terminal Agent states persist conclusion text already streamed through ans
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: `Streamed ${scenario.name}`, agentKinds: ['codex'], workdir: directory,
|
||||
allowWrite: false,
|
||||
})
|
||||
|
||||
const send = workspace.sendMessage({
|
||||
@@ -2808,6 +3133,7 @@ test('maximum Harness conclusion survives terminal persistence and authoritative
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: 'Maximum terminal conclusion', agentKinds: ['codex'], workdir: directory,
|
||||
allowWrite: false,
|
||||
})
|
||||
|
||||
await workspace.sendMessage({
|
||||
@@ -2849,7 +3175,7 @@ test('maximum Harness conclusion survives terminal persistence and authoritative
|
||||
)
|
||||
})
|
||||
|
||||
test('restart reconciles every terminal Agent checkpoint with its real status and output', async (t) => {
|
||||
test('restart restores only the final failure after superseded Agent attempts', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const ledgerPath = path.join(directory, 'run-ledger.json')
|
||||
@@ -2902,14 +3228,9 @@ test('restart reconciles every terminal Agent checkpoint with its real status an
|
||||
assert.equal(byAgentRunId.get('agent-partial').role, 'agent')
|
||||
assert.equal(byAgentRunId.get('agent-partial').content, 'Partial output')
|
||||
assert.equal(byAgentRunId.get('agent-partial').trace.status, 'partial')
|
||||
assert.equal(byAgentRunId.get('agent-failed').system.key, 'system.agentCallFailed')
|
||||
assert.match(byAgentRunId.get('agent-failed').content, /Failure output/)
|
||||
assert.equal(byAgentRunId.get('agent-failed').trace.status, 'failed')
|
||||
assert.match(byAgentRunId.get('agent-failed-other-output').content, /Distinct failure output/)
|
||||
assert.equal(
|
||||
byAgentRunId.get('agent-failed-other-reason').system.params.reason,
|
||||
'LOCAL_AGENT_AUTH_FAILED',
|
||||
)
|
||||
assert.equal(byAgentRunId.has('agent-failed'), false)
|
||||
assert.equal(byAgentRunId.has('agent-failed-other-output'), false)
|
||||
assert.equal(byAgentRunId.has('agent-failed-other-reason'), false)
|
||||
assert.equal(byAgentRunId.get('agent-timeout').system.params.reason, 'LOCAL_AGENT_TIMEOUT')
|
||||
assert.match(byAgentRunId.get('agent-timeout').content, /Timeout output/)
|
||||
assert.equal(byAgentRunId.get('agent-timeout').trace.status, 'timeout')
|
||||
@@ -2924,7 +3245,7 @@ test('restart reconciles every terminal Agent checkpoint with its real status an
|
||||
})
|
||||
assert.equal(repeated.snapshot().messages.filter(message => (
|
||||
message.trace?.runId === 'run-terminal'
|
||||
)).length, 8)
|
||||
)).length, 5)
|
||||
})
|
||||
|
||||
test('stopping during output capture prevents the Agent from launching', async (t) => {
|
||||
@@ -3132,15 +3453,28 @@ test('the Agent watchdog covers output capture and import phases', async (t) =>
|
||||
name: `Watchdog ${phase}`, agentKinds: ['codex'], workdir: directory, allowWrite: true,
|
||||
})
|
||||
|
||||
await workspace.sendMessage({ groupId: group.id, text: 'Do not hang' })
|
||||
const send = workspace.sendMessage({ groupId: group.id, text: 'Do not hang' })
|
||||
if (phase === 'import') {
|
||||
const gate = await waitForPendingGate(workspace)
|
||||
const request = workspace.humanGateStore.request(gate.gateId)
|
||||
assert.equal(gate.type, 'retry')
|
||||
assert.equal(request.failureCategory, 'timeout')
|
||||
assert.equal(request.sideEffectsPossible, true)
|
||||
workspace.decideHumanGate(gate.gateId, {
|
||||
status: 'rejected', optionId: 'cancel-retry', actorId: 'local-user',
|
||||
})
|
||||
}
|
||||
await send
|
||||
|
||||
assert.equal(calls.length, phase === 'capture' ? 0 : 1)
|
||||
assert.equal(finished[0].status, 'timeout')
|
||||
const failure = workspace.snapshot().messages.find(message => (
|
||||
message.system?.key === 'system.agentCallFailed'
|
||||
))
|
||||
assert.equal(failure.system.params.reason, 'LOCAL_AGENT_TIMEOUT')
|
||||
assert.equal(failure.trace.status, 'timeout')
|
||||
assert.equal(finished[0].status, phase === 'capture' ? 'timeout' : 'stopped')
|
||||
if (phase === 'capture') {
|
||||
const failure = workspace.snapshot().messages.find(message => (
|
||||
message.system?.key === 'system.agentCallFailed'
|
||||
))
|
||||
assert.equal(failure.system.params.reason, 'LOCAL_AGENT_TIMEOUT')
|
||||
assert.equal(failure.trace.status, 'timeout')
|
||||
}
|
||||
assert.equal(workspace.snapshot().messages.some(message => message.role === 'agent'), false)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -391,6 +391,7 @@ async function verifyAutomaticGateRecovery(t, gateIndex) {
|
||||
attachmentRecipients: [],
|
||||
totalSuccesses: 0,
|
||||
terminalFailureOccurred: false,
|
||||
executionSequence: gateIndex + 1,
|
||||
})
|
||||
assert.equal(collaboration.version, 1)
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -3,7 +3,10 @@ const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const { runAgent } = require('../../src/agents/cli/cli-adapters.cjs')
|
||||
const {
|
||||
runAgent,
|
||||
shutdownAcpSessionRuntime,
|
||||
} = require('../../src/agents/cli/cli-adapters.cjs')
|
||||
const { LocalWorkspace } = require('../../src/workspace/local-workspace.cjs')
|
||||
const { createLegacyOutboundPayload } = require('../../src/collaboration/outbound-payload.cjs')
|
||||
const { RunLedger } = require('../../src/runs/run-ledger.cjs')
|
||||
@@ -460,7 +463,10 @@ test('LocalWorkspace persists and resumes ACP permission approval and rejection
|
||||
|
||||
test('ACP permission Gate survives shutdown and resumes the persisted Session after approval', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
t.after(async () => {
|
||||
await shutdownAcpSessionRuntime()
|
||||
fs.rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
const ledgerPath = path.join(directory, 'run-ledger.json')
|
||||
const cli = acpPermissionExecutable(directory)
|
||||
const ledger = new RunLedger({ storagePath: ledgerPath })
|
||||
|
||||
@@ -39,6 +39,103 @@ test('direct Hermes conversations reuse one persistent ACP runtime key across me
|
||||
assert.equal(calls[1].runOptions.acpPersistenceKey, calls[0].runOptions.acpPersistenceKey)
|
||||
})
|
||||
|
||||
test('group OpenCode conversations reuse one persistent ACP runtime key across rounds', async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
options.detectAgents = async () => [
|
||||
{ kind: 'codex', name: 'Codex CLI', executable: '/tmp/codex', version: '1' },
|
||||
{ kind: 'opencode', name: 'OpenCode CLI', executable: '/tmp/opencode', version: '1' },
|
||||
]
|
||||
options.runAgent = async (agent, prompt, workdir, runOptions) => {
|
||||
calls.push({ agent, prompt, workdir, runOptions })
|
||||
const sessionRef = runOptions.sessionRef || `${agent.kind}-acp-session`
|
||||
if (agent.kind === 'opencode') {
|
||||
await runOptions.onSessionRef(sessionRef, { transport: 'acp' })
|
||||
}
|
||||
return { text: `reply ${calls.length}`, sessionRef, outcome: 'completed' }
|
||||
}
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: 'OpenCode ACP group', agentKinds: ['codex', 'opencode'], workdir: directory,
|
||||
})
|
||||
|
||||
await workspace.sendMessage({ groupId: group.id, text: 'first', mode: 'auto', maxRounds: 2 })
|
||||
await workspace.activeRuns.get(group.id)?.promise
|
||||
|
||||
const opencodeCalls = calls.filter(call => call.agent.kind === 'opencode')
|
||||
assert.deepEqual(opencodeCalls.map(call => call.runOptions.sessionRef), ['', 'opencode-acp-session'])
|
||||
assert.deepEqual(opencodeCalls.map(call => call.runOptions.sessionTransport), ['', 'acp'])
|
||||
assert.equal(typeof opencodeCalls[0].runOptions.acpPersistenceKey, 'string')
|
||||
assert.equal(opencodeCalls[1].runOptions.acpPersistenceKey, opencodeCalls[0].runOptions.acpPersistenceKey)
|
||||
})
|
||||
|
||||
test('Natural V4 keeps Hermes, OpenCode, and Claude Code on one Session per group task',
|
||||
async (t) => {
|
||||
const { directory, calls, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
options.naturalAgentResponses = true
|
||||
options.detectAgents = async () => [
|
||||
{
|
||||
kind: 'hermes', name: 'Hermes CLI', executable: '/tmp/hermes', version: '2',
|
||||
acpAvailable: true,
|
||||
},
|
||||
{ kind: 'opencode', name: 'OpenCode CLI', executable: '/tmp/opencode', version: '1' },
|
||||
{ kind: 'claude', name: 'Claude Code', executable: '/tmp/claude', version: '1' },
|
||||
]
|
||||
options.runAgent = async (agent, prompt, workdir, runOptions) => {
|
||||
calls.push({ agent, prompt, workdir, runOptions })
|
||||
const sessionRef = runOptions.sessionRef || `${agent.kind}-native-session`
|
||||
if (['hermes', 'opencode'].includes(agent.kind)) {
|
||||
await runOptions.onSessionRef(sessionRef, { transport: 'acp' })
|
||||
}
|
||||
return { text: `${agent.kind} round response`, sessionRef, outcome: 'completed' }
|
||||
}
|
||||
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: 'Native Session continuity',
|
||||
agentKinds: ['hermes', 'opencode', 'claude'],
|
||||
workdir: directory,
|
||||
})
|
||||
|
||||
await workspace.sendMessage({
|
||||
groupId: group.id,
|
||||
text: 'Continue one shared task for three rounds.',
|
||||
mode: 'auto',
|
||||
discussionStyle: 'sequential',
|
||||
targetKinds: group.agentKinds,
|
||||
maxRounds: 3,
|
||||
protocol: 'v4',
|
||||
})
|
||||
const controller = workspace.activeRuns.get(group.id)
|
||||
assert.ok(controller)
|
||||
await controller.promise
|
||||
|
||||
for (const kind of group.agentKinds) {
|
||||
const agentCalls = calls.filter(call => call.agent.kind === kind)
|
||||
assert.deepEqual(agentCalls.map(call => call.runOptions.sessionRef), [
|
||||
'', `${kind}-native-session`, `${kind}-native-session`,
|
||||
])
|
||||
assert.equal(typeof agentCalls[0].runOptions.acpPersistenceKey, 'string')
|
||||
assert.equal(agentCalls[0].runOptions.acpPersistenceKey.length > 0, true)
|
||||
assert.equal(agentCalls.every(call => (
|
||||
call.runOptions.acpPersistenceKey === agentCalls[0].runOptions.acpPersistenceKey
|
||||
)), true)
|
||||
}
|
||||
assert.deepEqual(
|
||||
calls.filter(call => call.agent.kind === 'hermes')
|
||||
.map(call => call.runOptions.sessionTransport),
|
||||
['', 'acp', 'acp'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
calls.filter(call => call.agent.kind === 'opencode')
|
||||
.map(call => call.runOptions.sessionTransport),
|
||||
['', 'acp', 'acp'],
|
||||
)
|
||||
})
|
||||
|
||||
test('Workspace stream bridge redacts split credentials and paths while preserving tools', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
|
||||
@@ -47,6 +47,22 @@ test('failure records its reason before checkpointing and persists streamed outp
|
||||
assert.equal(controller.agentFailureReasons.get(trace().agentRunId), 'LOCAL_AGENT_FAILED')
|
||||
assert.deepEqual(calls.map(call => call[0]), ['checkpoint', 'message'])
|
||||
assert.equal(calls[1][3], 'Codex failed: LOCAL_AGENT_FAILED\nstreamed answer')
|
||||
assert.match(calls[1][7].responseVersionRootId, /^response-/)
|
||||
})
|
||||
|
||||
test('failure and success traces use the same version root for one round and phase', () => {
|
||||
const { calls, messages } = fixture()
|
||||
const runTrace = trace({ round: 2, phase: 'challenge' })
|
||||
messages.recordFailure(
|
||||
'group-1', 'codex', { message: 'LOCAL_AGENT_TIMEOUT', runTrace }, 'root-1',
|
||||
)
|
||||
const failureRoot = calls.at(-1)[7].responseVersionRootId
|
||||
const successRoot = messages.responseVersionRootId('group-1', 'codex', 'root-1', runTrace)
|
||||
assert.equal(failureRoot, successRoot)
|
||||
assert.notEqual(
|
||||
successRoot,
|
||||
messages.responseVersionRootId('group-1', 'codex', 'root-1', { round: 3, phase: 'challenge' }),
|
||||
)
|
||||
})
|
||||
|
||||
test('failure deduplication includes streamed output and still checkpoints duplicates', () => {
|
||||
|
||||
@@ -354,7 +354,7 @@ test('V4 keeps a complete minimal 32-Agent responsibility graph within the deliv
|
||||
targetKinds,
|
||||
assignments: minimalAssignments(targetKinds),
|
||||
finalizerKind: targetKinds.at(-1),
|
||||
verifierKinds: targetKinds.slice(0, 2),
|
||||
verifierKinds: targetKinds.slice(0, -1),
|
||||
agreedBy: targetKinds,
|
||||
})
|
||||
|
||||
@@ -1324,13 +1324,13 @@ test('V4 lets Agents negotiate responsibilities, execute every work package, and
|
||||
).size, 4)
|
||||
assert.deepEqual(
|
||||
calls.filter(call => call.phase === 'synthesis').map(call => call.kind),
|
||||
['workbuddy'],
|
||||
['workbuddy', 'workbuddy'],
|
||||
)
|
||||
assert.match(calls.find(call => call.phase === 'synthesis').prompt, /Role: integrator/)
|
||||
assert.equal(calls.find(call => call.phase === 'synthesis').sandbox, 'read-only')
|
||||
assert.deepEqual(
|
||||
calls.filter(call => call.phase === 'verification').map(call => call.kind).sort(),
|
||||
['codex', 'hermes'],
|
||||
['codex', 'codex', 'hermes', 'hermes'],
|
||||
)
|
||||
const finalEntry = controller.orchestration.collaboration.entries.at(-1)
|
||||
assert.equal(finalEntry.owner.agentKind, preferredPlan.finalizerKind)
|
||||
@@ -1343,9 +1343,33 @@ test('V4 lets Agents negotiate responsibilities, execute every work package, and
|
||||
controller.orchestration.slots.find(slot => slot.agentKind === preferredPlan.finalizerKind).permission,
|
||||
'read-only',
|
||||
)
|
||||
const visibleAgentMessages = workspace.snapshot().messages.filter(message => message.role === 'agent')
|
||||
assert.deepEqual(
|
||||
workspace.snapshot().messages.filter(message => message.role === 'agent')
|
||||
.map(message => message.content),
|
||||
visibleAgentMessages.slice(0, 3).map(message => message.agentKind),
|
||||
['codex', 'hermes', 'workbuddy'],
|
||||
)
|
||||
for (const message of visibleAgentMessages.slice(0, 3)) {
|
||||
assert.match(message.content, new RegExp(`^${message.agentKind} independent proposal BODY_FROM_${message.agentKind}`))
|
||||
}
|
||||
assert.deepEqual(
|
||||
visibleAgentMessages.slice(3, 6).map(message => message.content),
|
||||
[
|
||||
'codex responsibility proposal',
|
||||
'hermes responsibility proposal',
|
||||
'workbuddy responsibility proposal',
|
||||
],
|
||||
)
|
||||
assert.deepEqual(
|
||||
visibleAgentMessages.slice(6, 9).map(message => message.content),
|
||||
[
|
||||
'codex supports a corrected responsibility graph',
|
||||
'hermes supports a corrected responsibility graph',
|
||||
'workbuddy supports a corrected responsibility graph',
|
||||
],
|
||||
)
|
||||
assert.deepEqual(
|
||||
visibleAgentMessages.slice(9).map(message => message.content),
|
||||
['Final delivery assembled from all agreed work products.'],
|
||||
)
|
||||
assert.equal(new Set(visibleAgentMessages.map(message => message.id)).size, 10)
|
||||
})
|
||||
@@ -131,19 +131,18 @@ async function exerciseAutoCommitCrash(t, crashPoint, input = {}) {
|
||||
return checkpointRun(groupId, controller, status)
|
||||
}
|
||||
workspace.autoRunner.commitV4AgentMessage = (input) => {
|
||||
if (crashPoint === 'pre-message') {
|
||||
const running = workspace.activeRuns.get(group.id)
|
||||
const running = workspace.activeRuns.get(group.id)
|
||||
const isCandidateMessage = running?.orchestration?.candidateCommit?.messageId === input.messageId
|
||||
if (crashPoint === 'pre-message' && isCandidateMessage) {
|
||||
crashRecord ||= structuredClone(ledger.get(running.runId))
|
||||
throw new Error('TEST_CRASH:AUTO_V4_PRE_MESSAGE')
|
||||
}
|
||||
const message = commitV4AgentMessage(input)
|
||||
if (crashPoint === 'stop-after-message') {
|
||||
const running = workspace.activeRuns.get(group.id)
|
||||
if (crashPoint === 'stop-after-message' && isCandidateMessage) {
|
||||
workspace.stop(group.id, running.runId)
|
||||
return message
|
||||
}
|
||||
if (crashPoint === 'post-message') {
|
||||
const running = workspace.activeRuns.get(group.id)
|
||||
if (crashPoint === 'post-message' && isCandidateMessage) {
|
||||
crashRecord ||= structuredClone(ledger.get(running.runId))
|
||||
throw new Error('TEST_CRASH:AUTO_V4_POST_MESSAGE')
|
||||
}
|
||||
@@ -160,14 +159,21 @@ async function exerciseAutoCommitCrash(t, crashPoint, input = {}) {
|
||||
})
|
||||
const controller = workspace.activeRuns.get(group.id)
|
||||
await controller.promise
|
||||
const proposalContents = ['codex proposal', 'hermes proposal', 'workbuddy proposal']
|
||||
const visibleAgentMessages = workspace.snapshot().messages.filter(message => (
|
||||
message.groupId === group.id && message.role === 'agent'
|
||||
))
|
||||
assert.deepEqual(
|
||||
visibleAgentMessages.filter(message => proposalContents.includes(message.content))
|
||||
.map(message => message.content),
|
||||
proposalContents,
|
||||
)
|
||||
if (['stop-after-message', 'stop-after-blackboard'].includes(crashPoint)) {
|
||||
const stopped = ledger.get(controller.runId)
|
||||
assert.equal(stopped.status, 'stopped')
|
||||
assert.notEqual(stopped.orchestration.candidateCommit?.status, 'completed')
|
||||
assert.equal(
|
||||
workspace.snapshot().messages.filter(message => (
|
||||
message.groupId === group.id && message.role === 'agent'
|
||||
)).length,
|
||||
visibleAgentMessages.filter(message => message.content === candidateBody).length,
|
||||
1,
|
||||
)
|
||||
assert.equal(
|
||||
@@ -208,9 +214,7 @@ async function exerciseAutoCommitCrash(t, crashPoint, input = {}) {
|
||||
? 1 : 0,
|
||||
)
|
||||
assert.equal(
|
||||
workspace.snapshot().messages.filter(message => (
|
||||
message.groupId === group.id && message.role === 'agent'
|
||||
)).length,
|
||||
visibleAgentMessages.filter(message => message.content === candidateBody).length,
|
||||
['post-message', 'post-message-checkpoint', 'post-blackboard-checkpoint',
|
||||
'pre-terminal', 'post-terminal'].includes(crashPoint) ? 1 : 0,
|
||||
)
|
||||
@@ -277,10 +281,17 @@ async function exerciseAutoCommitCrash(t, crashPoint, input = {}) {
|
||||
new Set(final.orchestration.slots.flatMap(slot => slot.resultRefs?.artifactIds || [])),
|
||||
initialArtifactIds,
|
||||
)
|
||||
const recoveredMessages = recovered.snapshot().messages.filter(message => (
|
||||
message.groupId === group.id && message.role === 'agent'
|
||||
))
|
||||
assert.deepEqual(
|
||||
recovered.snapshot().messages.filter(message => (
|
||||
message.groupId === group.id && message.role === 'agent'
|
||||
)).map(message => ({ id: message.id, content: message.content })),
|
||||
recoveredMessages.filter(message => proposalContents.includes(message.content))
|
||||
.map(message => message.content),
|
||||
proposalContents,
|
||||
)
|
||||
assert.deepEqual(
|
||||
recoveredMessages.filter(message => message.content === candidateBody)
|
||||
.map(message => ({ id: message.id, content: message.content })),
|
||||
[{ id: finalMessageId, content: candidateBody }],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -743,7 +743,13 @@ test('finite V4 round limit retains candidate and durable open issues without a
|
||||
assert.equal(durable.orchestration.convergence.openIssueIds.length, 1)
|
||||
assert.deepEqual(workspace.listHumanGates(), [])
|
||||
assert.deepEqual(
|
||||
workspace.snapshot().messages.filter(message => message.role === 'agent'),
|
||||
[],
|
||||
workspace.snapshot().messages.filter(message => message.role === 'agent')
|
||||
.map(message => ({ agentKind: message.agentKind, content: message.content })),
|
||||
[
|
||||
{ agentKind: 'codex', content: 'codex proposal' },
|
||||
{ agentKind: 'hermes', content: 'hermes proposal' },
|
||||
{ agentKind: 'codex', content: 'codex challenge' },
|
||||
{ agentKind: 'hermes', content: 'hermes challenge' },
|
||||
],
|
||||
)
|
||||
})
|
||||
@@ -1304,7 +1304,6 @@ test('Auto V4 rejects a terminal proposal while its live permission Gate is pend
|
||||
const gate = await pendingGate(workspace)
|
||||
await waitFor(() => calls.filter(call => call.phase === 'proposal').length === 3,
|
||||
'all proposal Agents dispatched')
|
||||
|
||||
assert.doesNotThrow(() => workspace.decideHumanGate(gate.gateId, {
|
||||
status: 'rejected', optionId: 'reject-once', actorId: 'local-user',
|
||||
}))
|
||||
|
||||
@@ -190,6 +190,55 @@ test('Manual V4 freezes one durable snapshot and checkpoints each queued slot in
|
||||
assert.ok(hermesCompleted >= 0 && hermesCompleted < codexCompleted)
|
||||
})
|
||||
|
||||
test('Manual V4 starts a four-Agent concurrent batch without task-level queueing', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const ledger = new RunLedger({
|
||||
storagePath: path.join(directory, 'run-ledger.json'),
|
||||
now: () => Date.now(),
|
||||
})
|
||||
options.runLedger = ledger
|
||||
options.runScheduler = new RunScheduler()
|
||||
const releases = new Map([
|
||||
['codex', deferred()],
|
||||
['hermes', deferred()],
|
||||
['openclaw', deferred()],
|
||||
['workbuddy', deferred()],
|
||||
])
|
||||
options.runAgent = agent => releases.get(agent.kind).promise
|
||||
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
const targetKinds = ['codex', 'hermes', 'openclaw', 'workbuddy']
|
||||
const group = workspace.createGroup({
|
||||
name: 'Four Agent concurrent batch',
|
||||
agentKinds: targetKinds,
|
||||
workdir: directory,
|
||||
allowWrite: false,
|
||||
})
|
||||
const send = workspace.sendMessage({
|
||||
groupId: group.id,
|
||||
text: 'Start every selected Agent together.',
|
||||
mode: 'manual',
|
||||
targetKinds,
|
||||
protocol: 'v4',
|
||||
})
|
||||
|
||||
const running = await waitFor(() => {
|
||||
const record = ledger.list(group.id)[0]
|
||||
return record?.orchestration?.slots.every(slot => slot.status === 'running') ? record : null
|
||||
}, 'all four slots running')
|
||||
assert.deepEqual(running.orchestration.currentKinds, targetKinds)
|
||||
assert.deepEqual(options.runScheduler.snapshot().queued, [])
|
||||
|
||||
for (const kind of targetKinds) releases.get(kind).resolve({
|
||||
text: `${kind} concurrent response`,
|
||||
sessionRef: `${kind}-session`,
|
||||
collaboration: proposalCollaboration(`${kind} concurrent summary`),
|
||||
})
|
||||
await send
|
||||
})
|
||||
|
||||
test('Manual V4 keeps its durable result body separate at the reported Artifact cap', async (t) => {
|
||||
for (const reportedCount of [63, 64]) {
|
||||
await t.test(`${reportedCount} reported Artifact IDs`, async (t) => {
|
||||
@@ -865,6 +914,7 @@ test('Manual V4 rejects a late slot result after the batch is stopped', async (t
|
||||
assert.equal(lateSlot.resultBodyArtifactId, undefined)
|
||||
assert.deepEqual(lateSlot.resultRefs?.artifactIds || [], [])
|
||||
assert.equal(final.orchestration.commitState.committedKinds.includes('codex'), false)
|
||||
assert.deepEqual(final.orchestration.commitState.committedKinds, ['hermes', 'workbuddy'])
|
||||
assert.equal(final.orchestration.collaboration.entries.some(entry => (
|
||||
entry.owner?.agentKind === 'codex'
|
||||
)), false)
|
||||
@@ -873,6 +923,76 @@ test('Manual V4 rejects a late slot result after the batch is stopped', async (t
|
||||
&& message.agentKind === 'codex'
|
||||
&& message.content === 'codex late result must be ignored'
|
||||
)), false)
|
||||
assert.deepEqual(workspace.snapshot().messages.filter(message => (
|
||||
message.role === 'agent' && message.threadRootId === running.threadRootId
|
||||
)).map(message => `${message.agentKind}:${message.content}`), [
|
||||
'hermes:hermes settled before stop',
|
||||
'workbuddy:workbuddy settled before stop',
|
||||
])
|
||||
})
|
||||
|
||||
test('Manual V4 persists streamed output from a slot stopped by the user', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
|
||||
const ledger = new RunLedger({
|
||||
storagePath: path.join(directory, 'run-ledger.json'),
|
||||
now: () => Date.now(),
|
||||
})
|
||||
options.runLedger = ledger
|
||||
options.runScheduler = new RunScheduler({ taskLimit: 2, workspaceLimit: 2, globalLimit: 2 })
|
||||
const codexStarted = deferred()
|
||||
options.runAgent = async (agent, _prompt, _workdir, runOptions) => {
|
||||
if (agent.kind !== 'codex') return {
|
||||
text: 'hermes settled before stop',
|
||||
sessionRef: 'hermes-session',
|
||||
collaboration: proposalCollaboration('hermes settled summary'),
|
||||
}
|
||||
runOptions.onEvent({
|
||||
type: 'answer_delta',
|
||||
status: 'running',
|
||||
delta: 'codex partial streamed response',
|
||||
})
|
||||
codexStarted.resolve()
|
||||
return await new Promise((_resolve, reject) => {
|
||||
runOptions.signal.addEventListener(
|
||||
'abort', () => reject(new Error('LOCAL_AGENT_EXECUTION_STOPPED')), { once: true },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const workspace = new LocalWorkspace(options)
|
||||
await workspace.refreshAgents()
|
||||
const group = workspace.createGroup({
|
||||
name: 'Manual V4 streamed stop',
|
||||
agentKinds: ['codex', 'hermes'],
|
||||
workdir: directory,
|
||||
allowWrite: false,
|
||||
})
|
||||
const send = workspace.sendMessage({
|
||||
groupId: group.id,
|
||||
text: 'Keep partial output when this batch is stopped.',
|
||||
mode: 'manual',
|
||||
targetKinds: ['codex', 'hermes'],
|
||||
protocol: 'v4',
|
||||
})
|
||||
await codexStarted.promise
|
||||
const running = ledger.list(group.id)[0]
|
||||
|
||||
assert.equal(workspace.stop(group.id, running.runId), true)
|
||||
await send
|
||||
|
||||
const final = ledger.get(running.runId)
|
||||
const codexSlot = final.orchestration.slots.find(slot => slot.agentKind === 'codex')
|
||||
const stoppedMessage = workspace.snapshot().messages.find(message => (
|
||||
message.system?.key === 'system.agentStopped'
|
||||
&& message.agentKind === 'codex'
|
||||
&& message.threadRootId === running.threadRootId
|
||||
))
|
||||
assert.equal(final.status, 'stopped')
|
||||
assert.equal(codexSlot.status, 'stopped')
|
||||
assert.equal(stoppedMessage.content, 'Codex was stopped.\ncodex partial streamed response')
|
||||
assert.equal(stoppedMessage.trace.status, 'stopped')
|
||||
assert.equal(stoppedMessage.trace.agentRunId, codexSlot.agentRunId)
|
||||
})
|
||||
|
||||
test('Manual V4 recovery fails closed when immutable recovery bodies are missing', async (t) => {
|
||||
|
||||
@@ -376,55 +376,27 @@ async function createManualPermissionRestart(t, restartOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
test('Manual V4 native ACP permission restart requires whole-slot recovery and a fresh Gate', async (t) => {
|
||||
test('Manual V4 native ACP permission restart resumes the persisted Session without a fresh Gate',
|
||||
async (t) => {
|
||||
const recovery = await createManualPermissionRestart(t)
|
||||
const {
|
||||
calls, originalGate, restarted, restartedLedger, beforeApprovalCalls, waiting, writerSlot,
|
||||
calls, originalGate, restarted, restartedLedger, beforeApprovalCalls, writerSlot,
|
||||
} = recovery
|
||||
const recoveryGate = await pendingGate(restarted, gate => (
|
||||
gate.gateId !== originalGate.gateId && gate.type === 'retry'
|
||||
))
|
||||
const recoveryRequest = restarted.humanGateStore.request(recoveryGate.gateId)
|
||||
const recovered = restartedLedger.get(originalGate.runId)
|
||||
const recoveredWriter = recovered.orchestration.slots.find(slot => slot.agentKind === 'hermes')
|
||||
const terminal = await terminalRun(restartedLedger, originalGate.runId)
|
||||
const recoveredWriter = terminal.orchestration.slots.find(slot => slot.agentKind === 'hermes')
|
||||
const hermesCalls = calls.filter(call => call.kind === 'hermes')
|
||||
|
||||
assert.equal(calls.length, beforeApprovalCalls + 1)
|
||||
assert.equal(calls.at(-1).sessionRef, 'hermes-v4-native-session')
|
||||
assert.equal(recovered.status, 'waiting')
|
||||
assert.equal(recoveredWriter.status, 'running')
|
||||
assert.equal(terminal.status, 'completed', terminal.reason)
|
||||
assert.equal(recoveredWriter.status, 'completed')
|
||||
assert.equal(recoveredWriter.attempt, writerSlot.attempt)
|
||||
assert.equal(recoveredWriter.operationId, writerSlot.operationId)
|
||||
assert.equal(recoveredWriter.commitStatus, 'pending')
|
||||
assert.deepEqual(recoveredWriter.resultRefs.workflowOutcomeRefs || [], [])
|
||||
assert.deepEqual(recoveryRequest, {
|
||||
phase: 'manual-writer-recovery',
|
||||
batchId: waiting.orchestration.batchId,
|
||||
slotId: writerSlot.slotId,
|
||||
operationId: writerSlot.operationId,
|
||||
attempt: writerSlot.attempt,
|
||||
outcomeCertainty: 'unknown_outcome',
|
||||
sideEffectsPossible: true,
|
||||
})
|
||||
for (const kind of ['codex', 'workbuddy']) {
|
||||
assert.equal(calls.filter(call => call.kind === kind).length, 1)
|
||||
}
|
||||
|
||||
restarted.decideHumanGate(recoveryGate.gateId, {
|
||||
status: 'approved', optionId: 'retry-once', actorId: 'local-user',
|
||||
})
|
||||
const freshPermissionGate = await pendingGate(restarted, gate => (
|
||||
gate.type === 'permission' && ![originalGate.gateId, recoveryGate.gateId].includes(gate.gateId)
|
||||
))
|
||||
assert.equal(calls.filter(call => call.kind === 'hermes').length, 3)
|
||||
assert.equal(calls.at(-1).sessionRef, '')
|
||||
assert.notEqual(freshPermissionGate.gateId, originalGate.gateId)
|
||||
|
||||
restarted.decideHumanGate(freshPermissionGate.gateId, {
|
||||
status: 'approved', optionId: 'allow-once', actorId: 'local-user',
|
||||
})
|
||||
const terminal = await terminalRun(restartedLedger, originalGate.runId)
|
||||
assert.equal(terminal.status, 'completed', terminal.reason)
|
||||
assert.equal(calls.filter(call => call.kind === 'hermes').length, 3)
|
||||
assert.equal(hermesCalls.length, 2)
|
||||
assert.deepEqual(hermesCalls.map(call => call.operationId), [
|
||||
writerSlot.operationId, writerSlot.operationId,
|
||||
])
|
||||
assert.equal(restarted.listHumanGates({ pendingOnly: true }).length, 0)
|
||||
for (const kind of ['codex', 'workbuddy']) {
|
||||
assert.equal(calls.filter(call => call.kind === kind).length, 1)
|
||||
}
|
||||
@@ -494,13 +466,28 @@ test('Manual V4 native permission rejects an already accepted bound operation be
|
||||
assert.equal(calls.length, beforeApprovalCalls)
|
||||
})
|
||||
|
||||
test('Manual V4 native permission restart recovers a missing or naturally rotated Session', async (t) => {
|
||||
test('Manual V4 native permission restart recovers a missing Session and preserves a V4 Session',
|
||||
async (t) => {
|
||||
for (const sessionState of ['missing', 'rotated']) {
|
||||
await t.test(sessionState, async (subtest) => {
|
||||
const recovery = await createManualPermissionRestart(subtest, { sessionState })
|
||||
const {
|
||||
calls, originalGate, restarted, restartedLedger, beforeApprovalCalls, writerSlot,
|
||||
} = recovery
|
||||
if (sessionState === 'rotated') {
|
||||
const terminal = await terminalRun(restartedLedger, originalGate.runId)
|
||||
const recoveredWriter = terminal.orchestration.slots.find(slot => (
|
||||
slot.agentKind === writerSlot.agentKind
|
||||
))
|
||||
assert.equal(calls.length, beforeApprovalCalls + 1)
|
||||
assert.equal(calls.at(-1).sessionRef, 'hermes-v4-native-session')
|
||||
assert.equal(terminal.status, 'completed', terminal.reason)
|
||||
assert.equal(recoveredWriter.status, 'completed')
|
||||
assert.equal(recoveredWriter.operationId, writerSlot.operationId)
|
||||
assert.equal(recoveredWriter.attempt, writerSlot.attempt)
|
||||
assert.equal(restarted.listHumanGates({ pendingOnly: true }).length, 0)
|
||||
return
|
||||
}
|
||||
const recoveryGate = await pendingGate(restarted, gate => (
|
||||
gate.type === 'retry' && gate.gateId !== originalGate.gateId
|
||||
))
|
||||
@@ -526,7 +513,7 @@ test('Manual V4 native permission restart recovers a missing or naturally rotate
|
||||
}
|
||||
})
|
||||
|
||||
test('Auto V4 read-only native ACP permission restart completes through a fresh Gate', async (t) => {
|
||||
test('Auto V4 read-only native ACP permission restart resumes the persisted Session', async (t) => {
|
||||
const { directory, options } = fixture()
|
||||
const cli = hermesAcpExecutable(directory)
|
||||
const ledger = new RunLedger({
|
||||
@@ -551,7 +538,7 @@ test('Auto V4 read-only native ACP permission restart completes through a fresh
|
||||
})
|
||||
const send = workspace.sendMessage({
|
||||
groupId: group.id,
|
||||
text: 'Resume one native read-only proposal through a fresh permission Gate.',
|
||||
text: 'Resume one native read-only proposal through its persisted Session.',
|
||||
mode: 'auto',
|
||||
maxRounds: 3,
|
||||
targetKinds: ['codex', 'hermes', 'workbuddy'],
|
||||
@@ -591,23 +578,19 @@ test('Auto V4 read-only native ACP permission restart completes through a fresh
|
||||
status: 'approved', optionId: 'allow-once', actorId: 'local-user',
|
||||
})
|
||||
|
||||
const freshGate = await pendingGate(restarted, gate => (
|
||||
gate.type === 'permission' && gate.gateId !== originalGate.gateId
|
||||
))
|
||||
const terminal = await terminalRun(restartedLedger, originalGate.runId)
|
||||
const resumedCalls = calls.filter(call => (
|
||||
call.kind === 'hermes' && call.phase === 'proposal'
|
||||
))
|
||||
assert.equal(calls.length, beforeApprovalCalls + 2)
|
||||
assert.equal(calls[beforeApprovalCalls].kind, 'hermes')
|
||||
assert.equal(calls[beforeApprovalCalls].phase, 'proposal')
|
||||
assert.equal(calls[beforeApprovalCalls].sessionRef, 'hermes-v4-native-session')
|
||||
assert.deepEqual(resumedCalls.map(call => call.operationId), [
|
||||
gatedSlot.operationId, gatedSlot.operationId, gatedSlot.operationId,
|
||||
gatedSlot.operationId, gatedSlot.operationId,
|
||||
])
|
||||
assert.equal(resumedCalls.at(-1).sessionRef, '')
|
||||
restarted.decideHumanGate(freshGate.gateId, {
|
||||
status: 'approved', optionId: 'allow-once', actorId: 'local-user',
|
||||
})
|
||||
|
||||
const terminal = await terminalRun(restartedLedger, originalGate.runId)
|
||||
assert.equal(resumedCalls.at(-1).sessionRef, 'hermes-v4-native-session')
|
||||
assert.equal(terminal.status, 'completed', terminal.reason)
|
||||
assert.equal(restarted.listHumanGates({ pendingOnly: true }).length, 0)
|
||||
for (const kind of ['codex', 'workbuddy']) {
|
||||
assert.equal(calls.filter(call => call.kind === kind && call.phase === 'proposal').length, 1)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user