mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-09 15:31:18 +08:00
Compare commits
147 Commits
vincentkoc
...
fix/gitign
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85c4eb7d26 | ||
|
|
b7be3f614a | ||
|
|
bda63c3c7f | ||
|
|
aca216bfcf | ||
|
|
c2eb12bbc5 | ||
|
|
6d0547dc2e | ||
|
|
568b0a22bb | ||
|
|
450d49ea52 | ||
|
|
3495563cfe | ||
|
|
9d403fd415 | ||
|
|
5296147c20 | ||
|
|
8306eabf85 | ||
|
|
45b74fb56c | ||
|
|
d1a59557b5 | ||
|
|
cf9db91b61 | ||
|
|
382287026b | ||
|
|
da4fec6641 | ||
|
|
96e4975922 | ||
|
|
989ee21b24 | ||
|
|
705c6a422d | ||
|
|
f0eb67923c | ||
|
|
93c44e3dad | ||
|
|
e42c4f4513 | ||
|
|
391f9430ca | ||
|
|
e74666cd0a | ||
|
|
731f1aa906 | ||
|
|
de49a8b72c | ||
|
|
9432a8bb3f | ||
|
|
25c2facc2b | ||
|
|
1720174757 | ||
|
|
5decb00e9d | ||
|
|
6b87489890 | ||
|
|
9f0a64f855 | ||
|
|
8e412bad0e | ||
|
|
8a6cd808a1 | ||
|
|
d648dd7643 | ||
|
|
5a659b0b61 | ||
|
|
c0cba7fb72 | ||
|
|
b48291e01e | ||
|
|
4790e40ac6 | ||
|
|
c9a6c542ef | ||
|
|
de4c3db3e3 | ||
|
|
64746c150c | ||
|
|
56f787e3c0 | ||
|
|
531e8362b1 | ||
|
|
3c3474360b | ||
|
|
0669b0ddc2 | ||
|
|
0c7f07818f | ||
|
|
4aebff78bc | ||
|
|
8e3f3bc3cf | ||
|
|
30340d6835 | ||
|
|
d346f2d9ce | ||
|
|
e6e4169e82 | ||
|
|
1bc59cc09d | ||
|
|
ef95975411 | ||
|
|
5f90883ad3 | ||
|
|
2b2e5e2038 | ||
|
|
0bcddb3d4f | ||
|
|
d86647d7db | ||
|
|
87d939be79 | ||
|
|
d4e59a3666 | ||
|
|
7b88249c9e | ||
|
|
12702e11a5 | ||
|
|
14bbcad169 | ||
|
|
eab39c721b | ||
|
|
4815dc0603 | ||
|
|
2cce45962f | ||
|
|
258b7902a4 | ||
|
|
425bd89b48 | ||
|
|
54be30ef89 | ||
|
|
fbf5d56366 | ||
|
|
98ea71aca5 | ||
|
|
51bae75120 | ||
|
|
f2f561fab1 | ||
|
|
f6d0712f50 | ||
|
|
6c579d7842 | ||
|
|
f9706fde6a | ||
|
|
7217b97658 | ||
|
|
ce9e91fdfc | ||
|
|
3caab9260c | ||
|
|
d0847ee322 | ||
|
|
1d3dde8d21 | ||
|
|
cc0f30f5fb | ||
|
|
250d3c949e | ||
|
|
5fca4c0de0 | ||
|
|
66c581c64c | ||
|
|
912aa8744a | ||
|
|
8d2d6db9ad | ||
|
|
2d55ad05f3 | ||
|
|
9631f4665c | ||
|
|
e2a1a4a3db | ||
|
|
f82931ba8b | ||
|
|
17599a8ea2 | ||
|
|
e86b38f09d | ||
|
|
1d301f74a6 | ||
|
|
2e79d82198 | ||
|
|
96d17f3cb1 | ||
|
|
79853aca9c | ||
|
|
2d5e70f3e7 | ||
|
|
6186f620d2 | ||
|
|
2767907abf | ||
|
|
9abf014f35 | ||
|
|
cf3a479bd1 | ||
|
|
fd902b0651 | ||
|
|
cf796e2a22 | ||
|
|
f84adcbe88 | ||
|
|
f184e7811c | ||
|
|
c79a0dbdb4 | ||
|
|
335223af32 | ||
|
|
6740cdf160 | ||
|
|
eea925b12b | ||
|
|
88aee9161e | ||
|
|
03a6e3b460 | ||
|
|
41e023a80b | ||
|
|
93775ef6a4 | ||
|
|
31402b8542 | ||
|
|
4bb8104810 | ||
|
|
1d6a2d0165 | ||
|
|
44beb7be1f | ||
|
|
69cd376e3b | ||
|
|
41eef15cdc | ||
|
|
41450187dd | ||
|
|
a40c29b11a | ||
|
|
d4a960fcca | ||
|
|
26e76f9a61 | ||
|
|
8befd88119 | ||
|
|
99cbda83a2 | ||
|
|
e8775cda93 | ||
|
|
ef36cb8cbc | ||
|
|
f114a5c638 | ||
|
|
a438ff4397 | ||
|
|
adec8b28bb | ||
|
|
e3df94365b | ||
|
|
4d501e4ccf | ||
|
|
f6243916b5 | ||
|
|
b34158086a | ||
|
|
eabda6e3a4 | ||
|
|
6d5e142b93 | ||
|
|
4f42c03a49 | ||
|
|
13bd3db307 | ||
|
|
ff4745fc3f | ||
|
|
c29b098744 | ||
|
|
24b53fcf47 | ||
|
|
dfc18b7a2b | ||
|
|
141738f717 | ||
|
|
4ff4ed7ec9 | ||
|
|
362248e559 |
31
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
31
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -76,6 +76,37 @@ body:
|
||||
label: Install method
|
||||
description: How OpenClaw was installed or launched.
|
||||
placeholder: npm global / pnpm dev / docker / mac app
|
||||
- type: input
|
||||
id: model
|
||||
attributes:
|
||||
label: Model
|
||||
description: Effective model under test.
|
||||
placeholder: minimax/text-01 / openrouter/anthropic/claude-opus-4.1 / anthropic/claude-sonnet-4.5
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: provider_chain
|
||||
attributes:
|
||||
label: Provider / routing chain
|
||||
description: Effective request path through gateways, proxies, providers, or model routers.
|
||||
placeholder: openclaw -> cloudflare-ai-gateway -> minimax
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: config_location
|
||||
attributes:
|
||||
label: Config file / key location
|
||||
description: Optional. Relevant config source or key path if this bug depends on overrides or custom provider setup. Redact secrets.
|
||||
placeholder: ~/.openclaw/openclaw.json ; models.providers.cloudflare-ai-gateway.baseUrl ; ~/.openclaw/agents/<agentId>/agent/models.json
|
||||
- type: textarea
|
||||
id: provider_setup_details
|
||||
attributes:
|
||||
label: Additional provider/model setup details
|
||||
description: Optional. Include redacted routing details, per-agent overrides, auth-profile interactions, env/config context, or anything else needed to explain the effective provider/model setup. Do not include API keys, tokens, or passwords.
|
||||
placeholder: |
|
||||
Default route is openclaw -> cloudflare-ai-gateway -> minimax.
|
||||
Previous setup was openclaw -> cloudflare-ai-gateway -> openrouter -> minimax.
|
||||
Relevant config lives in ~/.openclaw/openclaw.json under models.providers.minimax and models.providers.cloudflare-ai-gateway.
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
|
||||
1
.github/workflows/auto-response.yml
vendored
1
.github/workflows/auto-response.yml
vendored
@@ -51,6 +51,7 @@ jobs:
|
||||
},
|
||||
{
|
||||
label: "r: no-ci-pr",
|
||||
close: true,
|
||||
message:
|
||||
"Please don't make PRs for test failures on main.\n\n" +
|
||||
"The team is aware of those and will handle them directly on the codebase, not only fixing the tests but also investigating what the root cause is. Having to sift through test-fix-PRs (including some that have been out of date for weeks...) on top of that doesn't help. There are already way too many PRs for humans to manage; please don't make the flood worse.\n\n" +
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -93,7 +93,11 @@ jobs:
|
||||
|
||||
- name: Setup Swift build tools
|
||||
if: matrix.needs_swift_tools
|
||||
run: brew install xcodegen swiftlint swiftformat
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
xcodebuild -version
|
||||
brew install xcodegen swiftlint swiftformat
|
||||
swift --version
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
|
||||
8
.github/workflows/docker-release.yml
vendored
8
.github/workflows/docker-release.yml
vendored
@@ -109,8 +109,6 @@ jobs:
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
provenance: false
|
||||
push: true
|
||||
cache-from: type=gha,scope=docker-release-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-amd64
|
||||
|
||||
- name: Build and push amd64 slim image
|
||||
id: build-slim
|
||||
@@ -124,8 +122,6 @@ jobs:
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
provenance: false
|
||||
push: true
|
||||
cache-from: type=gha,scope=docker-release-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-amd64
|
||||
|
||||
# Build arm64 images (default + slim share the build stage cache)
|
||||
build-arm64:
|
||||
@@ -214,8 +210,6 @@ jobs:
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
provenance: false
|
||||
push: true
|
||||
cache-from: type=gha,scope=docker-release-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-arm64
|
||||
|
||||
- name: Build and push arm64 slim image
|
||||
id: build-slim
|
||||
@@ -229,8 +223,6 @@ jobs:
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
provenance: false
|
||||
push: true
|
||||
cache-from: type=gha,scope=docker-release-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-arm64
|
||||
|
||||
# Create multi-platform manifests
|
||||
create-manifest:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -121,3 +121,4 @@ dist/protocol.schema.json
|
||||
|
||||
# Synthing
|
||||
**/.stfolder/
|
||||
.dev-state
|
||||
|
||||
@@ -9,7 +9,19 @@ Input
|
||||
- If ambiguous: ask.
|
||||
|
||||
Do (review-only)
|
||||
Goal: produce a thorough review and a clear recommendation (READY for /landpr vs NEEDS WORK). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command.
|
||||
Goal: produce a thorough review and a clear recommendation (READY FOR /landpr vs NEEDS WORK vs INVALID CLAIM). Do NOT merge, do NOT push, do NOT make changes in the repo as part of this command.
|
||||
|
||||
0. Truthfulness + reality gate (required for bug-fix claims)
|
||||
- Do not trust the issue text or PR summary by default; verify in code and evidence.
|
||||
- If the PR claims to fix a bug linked to an issue, confirm the bug exists now (repro steps, logs, failing test, or clear code-path proof).
|
||||
- Prove root cause with exact location (`path/file.ts:line` + explanation of why behavior is wrong).
|
||||
- Verify fix targets the same code path as the root cause.
|
||||
- Require a regression test when feasible (fails before fix, passes after fix). If not feasible, require explicit justification + manual verification evidence.
|
||||
- Hallucination/BS red flags (treat as BLOCKER until disproven):
|
||||
- claimed behavior not present in repo,
|
||||
- issue/PR says "fixes #..." but changed files do not touch implicated path,
|
||||
- only docs/comments changed for a runtime bug claim,
|
||||
- vague AI-generated rationale without concrete evidence.
|
||||
|
||||
1. Identify PR meta + context
|
||||
|
||||
@@ -56,6 +68,7 @@ Goal: produce a thorough review and a clear recommendation (READY for /landpr vs
|
||||
- Any deprecations, docs, types, or lint rules we should adjust?
|
||||
|
||||
8. Key questions to answer explicitly
|
||||
- Is the core claim substantiated by evidence, or is it likely invalid/hallucinated?
|
||||
- Can we fix everything ourselves in a follow-up, or does the contributor need to update this PR?
|
||||
- Any blocking concerns (must-fix before merge)?
|
||||
- Is this PR ready to land, or does it need work?
|
||||
@@ -65,18 +78,32 @@ Goal: produce a thorough review and a clear recommendation (READY for /landpr vs
|
||||
|
||||
A) TL;DR recommendation
|
||||
|
||||
- One of: READY FOR /landpr | NEEDS WORK | NEEDS DISCUSSION
|
||||
- One of: READY FOR /landpr | NEEDS WORK | INVALID CLAIM (issue/bug not substantiated) | NEEDS DISCUSSION
|
||||
- 1–3 sentence rationale.
|
||||
|
||||
B) What changed
|
||||
B) Claim verification matrix (required)
|
||||
|
||||
- Fill this table:
|
||||
|
||||
| Field | Evidence |
|
||||
| ----------------------------------------------- | -------- |
|
||||
| Claimed problem | ... |
|
||||
| Evidence observed (repro/log/test/code) | ... |
|
||||
| Root cause location (`path:line`) | ... |
|
||||
| Why this fix addresses that root cause | ... |
|
||||
| Regression coverage (test name or manual proof) | ... |
|
||||
|
||||
- If any row is missing/weak, default to `NEEDS WORK` or `INVALID CLAIM`.
|
||||
|
||||
C) What changed
|
||||
|
||||
- Brief bullet summary of the diff/behavioral changes.
|
||||
|
||||
C) What's good
|
||||
D) What's good
|
||||
|
||||
- Bullets: correctness, simplicity, tests, docs, ergonomics, etc.
|
||||
|
||||
D) Concerns / questions (actionable)
|
||||
E) Concerns / questions (actionable)
|
||||
|
||||
- Numbered list.
|
||||
- Mark each item as:
|
||||
@@ -84,17 +111,19 @@ D) Concerns / questions (actionable)
|
||||
- IMPORTANT (should fix before merge)
|
||||
- NIT (optional)
|
||||
- For each: point to the file/area and propose a concrete fix or alternative.
|
||||
- If evidence for the core bug claim is missing, add a `BLOCKER` explicitly.
|
||||
|
||||
E) Tests
|
||||
F) Tests
|
||||
|
||||
- What exists.
|
||||
- What's missing (specific scenarios).
|
||||
- State clearly whether there is a regression test for the claimed bug.
|
||||
|
||||
F) Follow-ups (optional)
|
||||
G) Follow-ups (optional)
|
||||
|
||||
- Non-blocking refactors/tickets to open later.
|
||||
|
||||
G) Suggested PR comment (optional)
|
||||
H) Suggested PR comment (optional)
|
||||
|
||||
- Offer: "Want me to draft a PR comment to the author?"
|
||||
- If yes, provide a ready-to-paste comment summarizing the above, with clear asks.
|
||||
|
||||
@@ -205,7 +205,7 @@
|
||||
"filename": "apps/macos/Sources/OpenClawProtocol/GatewayModels.swift",
|
||||
"hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd",
|
||||
"is_verified": false,
|
||||
"line_number": 1763
|
||||
"line_number": 1859
|
||||
}
|
||||
],
|
||||
"apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift": [
|
||||
@@ -266,7 +266,7 @@
|
||||
"filename": "apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
|
||||
"hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd",
|
||||
"is_verified": false,
|
||||
"line_number": 1763
|
||||
"line_number": 1859
|
||||
}
|
||||
],
|
||||
"docs/.i18n/zh-CN.tm.jsonl": [
|
||||
@@ -11659,7 +11659,7 @@
|
||||
"filename": "src/agents/tools/web-search.ts",
|
||||
"hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b",
|
||||
"is_verified": false,
|
||||
"line_number": 292
|
||||
"line_number": 291
|
||||
}
|
||||
],
|
||||
"src/agents/tools/web-tools.enabled-defaults.e2e.test.ts": [
|
||||
@@ -13013,5 +13013,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2026-03-09T08:37:13Z"
|
||||
"generated_at": "2026-03-10T03:11:06Z"
|
||||
}
|
||||
|
||||
@@ -48,4 +48,4 @@
|
||||
--allman false
|
||||
|
||||
# Exclusions
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/MoltbotProtocol,apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/OpenClawProtocol,apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
|
||||
|
||||
@@ -18,7 +18,7 @@ excluded:
|
||||
- coverage
|
||||
- "*.playground"
|
||||
# Generated (protocol-gen-swift.ts)
|
||||
- apps/macos/Sources/MoltbotProtocol/GatewayModels.swift
|
||||
- apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
|
||||
# Generated (generate-host-env-security-policy-swift.mjs)
|
||||
- apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
|
||||
|
||||
|
||||
29
AGENTS.md
29
AGENTS.md
@@ -10,6 +10,35 @@
|
||||
- GitHub searching footgun: don't limit yourself to the first 500 issues or PRs when wanting to search all. Unless you're supposed to look at the most recent, keep going until you've reached the last page in the search
|
||||
- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries.
|
||||
|
||||
## Auto-close labels (issues and PRs)
|
||||
|
||||
- If an issue/PR matches one of the reasons below, apply the label and let `.github/workflows/auto-response.yml` handle comment/close/lock.
|
||||
- Do not manually close + manually comment for these reasons.
|
||||
- Why: keeps wording consistent, preserves automation behavior (`state_reason`, locking), and keeps triage/reporting searchable by label.
|
||||
- `r:*` labels can be used on both issues and PRs.
|
||||
|
||||
- `r: skill`: close with guidance to publish skills on Clawhub.
|
||||
- `r: support`: close with redirect to Discord support + stuck FAQ.
|
||||
- `r: no-ci-pr`: close test-fix-only PRs for failing `main` CI and post the standard explanation.
|
||||
- `r: too-many-prs`: close when author exceeds active PR limit.
|
||||
- `r: testflight`: close requests asking for TestFlight access/builds. OpenClaw does not provide TestFlight distribution yet, so use the standard response (“Not available, build from source.”) instead of ad-hoc replies.
|
||||
- `r: third-party-extension`: close with guidance to ship as third-party plugin.
|
||||
- `r: moltbook`: close + lock as off-topic (not affiliated).
|
||||
- `invalid`: close invalid items (issues are closed as `not_planned`; PRs are closed).
|
||||
- `dirty`: close PRs with too many unrelated/unexpected changes (PR-only label).
|
||||
|
||||
## PR truthfulness and bug-fix validation
|
||||
|
||||
- Never merge a bug-fix PR based only on issue text, PR text, or AI rationale.
|
||||
- Before `/landpr`, run `/reviewpr` and require explicit evidence for bug-fix claims.
|
||||
- Minimum merge gate for bug-fix PRs:
|
||||
1. symptom evidence (repro/log/failing test),
|
||||
2. verified root cause in code with file/line,
|
||||
3. fix touches the implicated code path,
|
||||
4. regression test (fail before/pass after) when feasible; if not feasible, include manual verification proof and why no test was added.
|
||||
- If claim is unsubstantiated or likely hallucinated/BS: do not merge. Request evidence/changes, or close with `invalid` when appropriate.
|
||||
- If linked issue appears wrong/outdated, correct triage first; do not merge speculative fixes.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||
|
||||
45
CHANGELOG.md
45
CHANGELOG.md
@@ -6,10 +6,19 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Gateway/node pending work: add narrow in-memory pending-work queue primitives (`node.pending.enqueue` / `node.pending.drain`) and wake-helper reuse as a foundation for dormant-node work delivery. (#41409) Thanks @mbelinky.
|
||||
- Git/runtime state: ignore the gateway-generated `.dev-state` file so local runtime state does not show up as untracked repo noise. (#41848) Thanks @smysle.
|
||||
- ACP/sessions_spawn: add optional `resumeSessionId` for `runtime: "acp"` so spawned ACP sessions can resume an existing ACPX/Codex conversation instead of always starting fresh. (#41847) Thanks @pejmanjohn.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Cron/doctor: tighten isolated cron delivery so cron jobs can no longer notify through ad hoc agent sends or fallback main-session summaries, and add `openclaw doctor --fix` migration for legacy cron storage and legacy notify/webhook delivery metadata. (#40998) Thanks @mbelinky.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Resolve web tool SecretRefs atomically at runtime. (#41599) Thanks @joshavant.
|
||||
- Feishu/local image auto-convert: pass `mediaLocalRoots` through the `sendText` local-image shim so allowed local image paths upload as Feishu images again instead of falling back to raw path text. (#40623) Thanks @ayanesakura.
|
||||
- ACP/ACPX plugin: bump the bundled `acpx` pin to `0.1.16` so plugin-local installs and strict version checks match the latest published CLI. (#41975) Thanks @dutifulbob.
|
||||
- macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes.
|
||||
- Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark.
|
||||
- Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz.
|
||||
@@ -18,6 +27,39 @@ Docs: https://docs.openclaw.ai
|
||||
- ACP/sessions.patch: allow `spawnedBy` and `spawnDepth` lineage fields on ACP session keys so `sessions_spawn` with `runtime: "acp"` no longer fails during child-session setup. Fixes #40971. (#40995) thanks @xaeon2026.
|
||||
- ACP/stop reason mapping: resolve gateway chat `state: "error"` completions as ACP `end_turn` instead of `refusal` so transient backend failures are not surfaced as deliberate refusals. (#41187) thanks @pejmanjohn.
|
||||
- ACP/setSessionMode: propagate gateway `sessions.patch` failures back to ACP clients so rejected mode changes no longer return silent success. (#41185) thanks @pejmanjohn.
|
||||
- Agents/embedded logs: add structured, sanitized lifecycle and failover observation events so overload and provider failures are easier to tail and filter. (#41336) thanks @altaywtf.
|
||||
- iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky.
|
||||
- Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927.
|
||||
- Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x.
|
||||
- Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky.
|
||||
- ACP/bridge mode: reject unsupported per-session MCP server setup and propagate rejected session-mode changes so IDE clients see explicit bridge limitations instead of silent success. (#41424) Thanks @mbelinky.
|
||||
- ACP/session UX: replay stored user and assistant text on `loadSession`, expose Gateway-backed session controls and metadata, and emit approximate session usage updates so IDE clients restore context more faithfully. (#41425) Thanks @mbelinky.
|
||||
- ACP/tool streaming: enrich `tool_call` and `tool_call_update` events with best-effort text content and file-location hints so IDE clients can follow bridge tool activity more naturally. (#41442) Thanks @mbelinky.
|
||||
- ACP/runtime attachments: forward normalized inbound image attachments into ACP runtime turns so ACPX sessions can preserve image prompt content on the runtime path. (#41427) Thanks @mbelinky.
|
||||
- ACP/regressions: add gateway RPC coverage for ACP lineage patching, ACPX runtime coverage for image prompt serialization, and an operator smoke-test procedure for live ACP spawn verification. (#41456) Thanks @mbelinky.
|
||||
- Agents/billing recovery: probe single-provider billing cooldowns on the existing throttle so topping up credits can recover without a manual gateway restart. (#41422) thanks @altaywtf.
|
||||
- ACP/follow-up hardening: make session restore and prompt completion degrade gracefully on transcript/update failures, enforce bounded tool-location traversal, and skip non-image ACPX turns the runtime cannot serialize. (#41464) Thanks @mbelinky.
|
||||
- Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf.
|
||||
- Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky.
|
||||
- Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera.
|
||||
- Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf.
|
||||
- Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk.
|
||||
- Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu.
|
||||
- CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth.
|
||||
- Tools/web search: treat Brave `llm-context` grounding snippets as plain strings so `web_search` no longer returns empty snippet arrays in LLM Context mode. (#41387) thanks @zheliu2.
|
||||
- Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo.
|
||||
- Control UI/Sessions: restore single-column session table collapse on narrow viewport or container widths by moving the responsive table override next to the base grid rule and enabling inline-size container queries. (#12175) Thanks @benjipeng.
|
||||
- Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev.
|
||||
- Cron/state errors: record `lastErrorReason` in cron job state and keep the gateway schema aligned with the full failover-reason set, including regression coverage for protocol conformance. (#14382) thanks @futuremind2026.
|
||||
- Tools/web search: recover OpenRouter Perplexity citation extraction from `message.annotations` when chat-completions responses omit top-level citations. (#40881) Thanks @laurieluo.
|
||||
- Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94.
|
||||
- Telegram/network env-proxy: apply configured transport policy to proxied HTTPS dispatchers as well as direct `NO_PROXY` bypasses, so resolver-scoped IPv4 fallback and network settings work consistently for env-proxied Telegram traffic. (#40740) Thanks @sircrumpet.
|
||||
- Agents/memory flush: forward `memoryFlushWritePath` through `runEmbeddedPiAgent` so memory-triggered flush turns keep the append-only write guard without aborting before tool setup. Follows up on #38574. (#41761) Thanks @frankekn.
|
||||
- CI/CodeQL Swift toolchain: select Xcode 26.1 before installing Swift build tools so the CodeQL Swift job uses Swift tools 6.2 on `macos-latest`. (#41787) thanks @BunsDev.
|
||||
- Sandbox/subagents: pass the real configured workspace through `sessions_spawn` inheritance when a parent agent runs in a copied-workspace sandbox, so child `/agent` mounts point at the configured workspace instead of the parent sandbox copy. (#40757) Thanks @dsantoreis.
|
||||
- Mattermost/plugin send actions: normalize direct `replyTo` fallback handling so threaded plugin sends trim blank IDs and reuse the correct reply target again. (#41176) Thanks @hnykda.
|
||||
- MS Teams/allowlist resolution: use the General channel conversation ID as the resolved team key (with Graph GUID fallback) so Bot Framework runtime `channelData.team.id` matching works for team and team/channel allowlist entries. (#41838) Thanks @BradGroux.
|
||||
- Mattermost/Markdown formatting: preserve first-line indentation when stripping bot mentions so nested list items and indented code blocks keep their structure, and render Mattermost tables natively by default instead of fenced-code fallback. (#18655) thanks @echo931.
|
||||
|
||||
## 2026.3.8
|
||||
|
||||
@@ -83,6 +125,7 @@ Docs: https://docs.openclaw.ai
|
||||
- MS Teams/authz: keep `groupPolicy: "allowlist"` enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent.
|
||||
- Security/system.run: bind approved `bun` and `deno run` script operands to on-disk file snapshots so post-approval script rewrites are denied before execution.
|
||||
- Skills/download installs: pin the validated per-skill tools root before writing downloaded archives, so rebinding the lexical tools path cannot redirect download writes outside the intended tools directory. Thanks @tdjackey.
|
||||
- Control UI/Debug: replace the Manual RPC free-text method field with a sorted dropdown sourced from gateway-advertised methods, and stack the form vertically for narrower layouts. (#14967) thanks @rixau.
|
||||
|
||||
## 2026.3.7
|
||||
|
||||
@@ -440,6 +483,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Control UI/Telegram sender labels: preserve inbound sender labels in sanitized chat history so dashboard user-message groups split correctly and show real group-member names instead of `You`. (#39414) Thanks @obviyus.
|
||||
- Agents/failover 402 recovery: keep temporary spend-limit `402` payloads retryable, preserve explicit insufficient-credit billing detection even in long provider payloads, and allow throttled billing-cooldown probes so single-provider setups can recover instead of staying locked out. (#38533) Thanks @xialonglee.
|
||||
- Browser/config schema: accept `browser.profiles.*.driver: "openclaw"` while preserving legacy `"clawd"` compatibility in validated config. (#39374; based on #35621) Thanks @gambletan and @ingyukoh.
|
||||
- Memory flush/bootstrap file protection: restrict memory-flush runs to append-only `read`/`write` tools and route host-side memory appends through root-enforced safe file handles so flush turns cannot overwrite bootstrap files via `exec` or unsafe raw rewrites. (#38574) Thanks @frankekn.
|
||||
- Mattermost/DM media uploads: resolve bare 26-character Mattermost IDs user-first for direct messages so media sends no longer fail with `403 Forbidden` when targets are configured as unprefixed user IDs. (#29925) Thanks @teconomix.
|
||||
|
||||
## 2026.3.2
|
||||
|
||||
|
||||
@@ -362,7 +362,14 @@ final class NodeAppModel {
|
||||
await MainActor.run {
|
||||
self.operatorConnected = false
|
||||
self.gatewayConnected = false
|
||||
// Foreground recovery must actively restart the saved gateway config.
|
||||
// Disconnecting stale sockets alone can leave us idle if the old
|
||||
// reconnect tasks were suppressed or otherwise got stuck in background.
|
||||
self.gatewayStatusText = "Reconnecting…"
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
if let cfg = self.activeGatewayConnectConfig {
|
||||
self.applyGatewayConnectConfig(cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -950,6 +950,102 @@ public struct NodeEventParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePendingDrainParams: Codable, Sendable {
|
||||
public let maxitems: Int?
|
||||
|
||||
public init(
|
||||
maxitems: Int?)
|
||||
{
|
||||
self.maxitems = maxitems
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case maxitems = "maxItems"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePendingDrainResult: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let revision: Int
|
||||
public let items: [[String: AnyCodable]]
|
||||
public let hasmore: Bool
|
||||
|
||||
public init(
|
||||
nodeid: String,
|
||||
revision: Int,
|
||||
items: [[String: AnyCodable]],
|
||||
hasmore: Bool)
|
||||
{
|
||||
self.nodeid = nodeid
|
||||
self.revision = revision
|
||||
self.items = items
|
||||
self.hasmore = hasmore
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case nodeid = "nodeId"
|
||||
case revision
|
||||
case items
|
||||
case hasmore = "hasMore"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePendingEnqueueParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let type: String
|
||||
public let priority: String?
|
||||
public let expiresinms: Int?
|
||||
public let wake: Bool?
|
||||
|
||||
public init(
|
||||
nodeid: String,
|
||||
type: String,
|
||||
priority: String?,
|
||||
expiresinms: Int?,
|
||||
wake: Bool?)
|
||||
{
|
||||
self.nodeid = nodeid
|
||||
self.type = type
|
||||
self.priority = priority
|
||||
self.expiresinms = expiresinms
|
||||
self.wake = wake
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case nodeid = "nodeId"
|
||||
case type
|
||||
case priority
|
||||
case expiresinms = "expiresInMs"
|
||||
case wake
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePendingEnqueueResult: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let revision: Int
|
||||
public let queued: [String: AnyCodable]
|
||||
public let waketriggered: Bool
|
||||
|
||||
public init(
|
||||
nodeid: String,
|
||||
revision: Int,
|
||||
queued: [String: AnyCodable],
|
||||
waketriggered: Bool)
|
||||
{
|
||||
self.nodeid = nodeid
|
||||
self.revision = revision
|
||||
self.queued = queued
|
||||
self.waketriggered = waketriggered
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case nodeid = "nodeId"
|
||||
case revision
|
||||
case queued
|
||||
case waketriggered = "wakeTriggered"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeInvokeRequestEvent: Codable, Sendable {
|
||||
public let id: String
|
||||
public let nodeid: String
|
||||
|
||||
@@ -950,6 +950,102 @@ public struct NodeEventParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePendingDrainParams: Codable, Sendable {
|
||||
public let maxitems: Int?
|
||||
|
||||
public init(
|
||||
maxitems: Int?)
|
||||
{
|
||||
self.maxitems = maxitems
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case maxitems = "maxItems"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePendingDrainResult: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let revision: Int
|
||||
public let items: [[String: AnyCodable]]
|
||||
public let hasmore: Bool
|
||||
|
||||
public init(
|
||||
nodeid: String,
|
||||
revision: Int,
|
||||
items: [[String: AnyCodable]],
|
||||
hasmore: Bool)
|
||||
{
|
||||
self.nodeid = nodeid
|
||||
self.revision = revision
|
||||
self.items = items
|
||||
self.hasmore = hasmore
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case nodeid = "nodeId"
|
||||
case revision
|
||||
case items
|
||||
case hasmore = "hasMore"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePendingEnqueueParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let type: String
|
||||
public let priority: String?
|
||||
public let expiresinms: Int?
|
||||
public let wake: Bool?
|
||||
|
||||
public init(
|
||||
nodeid: String,
|
||||
type: String,
|
||||
priority: String?,
|
||||
expiresinms: Int?,
|
||||
wake: Bool?)
|
||||
{
|
||||
self.nodeid = nodeid
|
||||
self.type = type
|
||||
self.priority = priority
|
||||
self.expiresinms = expiresinms
|
||||
self.wake = wake
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case nodeid = "nodeId"
|
||||
case type
|
||||
case priority
|
||||
case expiresinms = "expiresInMs"
|
||||
case wake
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePendingEnqueueResult: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let revision: Int
|
||||
public let queued: [String: AnyCodable]
|
||||
public let waketriggered: Bool
|
||||
|
||||
public init(
|
||||
nodeid: String,
|
||||
revision: Int,
|
||||
queued: [String: AnyCodable],
|
||||
waketriggered: Bool)
|
||||
{
|
||||
self.nodeid = nodeid
|
||||
self.revision = revision
|
||||
self.queued = queued
|
||||
self.waketriggered = waketriggered
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case nodeid = "nodeId"
|
||||
case revision
|
||||
case queued
|
||||
case waketriggered = "wakeTriggered"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeInvokeRequestEvent: Codable, Sendable {
|
||||
public let id: String
|
||||
public let nodeid: String
|
||||
|
||||
49
docs.acp.md
49
docs.acp.md
@@ -17,6 +17,51 @@ Key goals:
|
||||
- Works with existing Gateway session store (list/resolve/reset).
|
||||
- Safe defaults (isolated ACP session keys by default).
|
||||
|
||||
## Bridge Scope
|
||||
|
||||
`openclaw acp` is a Gateway-backed ACP bridge, not a full ACP-native editor
|
||||
runtime. It is designed to route IDE prompts into an existing OpenClaw Gateway
|
||||
session with predictable session mapping and basic streaming updates.
|
||||
|
||||
## Compatibility Matrix
|
||||
|
||||
| ACP area | Status | Notes |
|
||||
| --------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. |
|
||||
| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. |
|
||||
| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key and replays stored user/assistant text history. Tool/system history is not reconstructed yet. |
|
||||
| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. |
|
||||
| Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. |
|
||||
| Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. |
|
||||
| Tool streaming | Partial | `tool_call` / `tool_call_update` events include raw I/O, text content, and best-effort file locations when Gateway tool args/results expose them. Embedded terminals and richer diff-native output are still not exposed. |
|
||||
| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. |
|
||||
| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. |
|
||||
| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. |
|
||||
| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. |
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- `loadSession` replays stored user and assistant text history, but it does not
|
||||
reconstruct historic tool calls, system notices, or richer ACP-native event
|
||||
types.
|
||||
- If multiple ACP clients share the same Gateway session key, event and cancel
|
||||
routing are best-effort rather than strictly isolated per client. Prefer the
|
||||
default isolated `acp:<uuid>` sessions when you need clean editor-local
|
||||
turns.
|
||||
- Gateway stop states are translated into ACP stop reasons, but that mapping is
|
||||
less expressive than a fully ACP-native runtime.
|
||||
- Initial session controls currently surface a focused subset of Gateway knobs:
|
||||
thought level, tool verbosity, reasoning, usage detail, and elevated
|
||||
actions. Model selection and exec-host controls are not yet exposed as ACP
|
||||
config options.
|
||||
- `session_info_update` and `usage_update` are derived from Gateway session
|
||||
snapshots, not live ACP-native runtime accounting. Usage is approximate,
|
||||
carries no cost data, and is only emitted when the Gateway marks total token
|
||||
data as fresh.
|
||||
- Tool follow-along data is best-effort. The bridge can surface file paths that
|
||||
appear in known tool args/results, but it does not yet emit ACP terminals or
|
||||
structured file diffs.
|
||||
|
||||
## How can I use this
|
||||
|
||||
Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to
|
||||
@@ -181,9 +226,11 @@ updates. Terminal Gateway states map to ACP `done` with stop reasons:
|
||||
|
||||
## Compatibility
|
||||
|
||||
- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.13.x).
|
||||
- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.15.x).
|
||||
- Works with ACP clients that implement `initialize`, `newSession`,
|
||||
`loadSession`, `prompt`, `cancel`, and `listSessions`.
|
||||
- Bridge mode rejects per-session `mcpServers` instead of silently ignoring
|
||||
them. Configure MCP at the Gateway or agent layer.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
|
||||
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
|
||||
- Webhook posting is per job via `delivery.mode = "webhook"` + `delivery.to = "<url>"`.
|
||||
- Legacy fallback remains for stored jobs with `notify: true` when `cron.webhook` is set, migrate those jobs to webhook delivery mode.
|
||||
- For upgrades, `openclaw doctor --fix` can normalize legacy cron store fields before the scheduler touches them.
|
||||
|
||||
## Quick start (actionable)
|
||||
|
||||
@@ -261,6 +262,7 @@ If `delivery.channel` or `delivery.to` is omitted, cron can fall back to the mai
|
||||
Target format reminders:
|
||||
|
||||
- Slack/Discord/Mattermost (plugin) targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
|
||||
Mattermost bare 26-char IDs are resolved **user-first** (DM if user exists, channel otherwise) — use `user:<id>` or `channel:<id>` for deterministic routing.
|
||||
- Telegram topics should use the `:topic:` form (see below).
|
||||
|
||||
#### Telegram delivery targets (topics / forum threads)
|
||||
|
||||
@@ -153,7 +153,14 @@ Use these target formats with `openclaw message send` or cron/webhooks:
|
||||
- `user:<id>` for a DM
|
||||
- `@username` for a DM (resolved via the Mattermost API)
|
||||
|
||||
Bare IDs are treated as channels.
|
||||
Bare opaque IDs (like `64ifufp...`) are **ambiguous** in Mattermost (user ID vs channel ID).
|
||||
|
||||
OpenClaw resolves them **user-first**:
|
||||
|
||||
- If the ID exists as a user (`GET /api/v4/users/<id>` succeeds), OpenClaw sends a **DM** by resolving the direct channel via `/api/v4/channels/direct`.
|
||||
- Otherwise the ID is treated as a **channel ID**.
|
||||
|
||||
If you need deterministic behavior, always use the explicit prefixes (`user:<id>` / `channel:<id>`).
|
||||
|
||||
## Reactions (message tool)
|
||||
|
||||
|
||||
@@ -760,6 +760,34 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \
|
||||
- `channels.telegram.actions.poll=false` disables Telegram poll creation while leaving regular sends enabled
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Exec approvals in Telegram">
|
||||
Telegram supports exec approvals in approver DMs and can optionally post approval prompts in the originating chat or topic.
|
||||
|
||||
Config path:
|
||||
|
||||
- `channels.telegram.execApprovals.enabled`
|
||||
- `channels.telegram.execApprovals.approvers`
|
||||
- `channels.telegram.execApprovals.target` (`dm` | `channel` | `both`, default: `dm`)
|
||||
- `agentFilter`, `sessionFilter`
|
||||
|
||||
Approvers must be numeric Telegram user IDs. When `enabled` is false or `approvers` is empty, Telegram does not act as an exec approval client. Approval requests fall back to other configured approval routes or the exec approval fallback policy.
|
||||
|
||||
Delivery rules:
|
||||
|
||||
- `target: "dm"` sends approval prompts only to configured approver DMs
|
||||
- `target: "channel"` sends the prompt back to the originating Telegram chat/topic
|
||||
- `target: "both"` sends to approver DMs and the originating chat/topic
|
||||
|
||||
Only configured approvers can approve or deny. Non-approvers cannot use `/approve` and cannot use Telegram approval buttons.
|
||||
|
||||
Channel delivery shows the command text in the chat, so only enable `channel` or `both` in trusted groups/topics. When the prompt lands in a forum topic, OpenClaw preserves the topic for both the approval prompt and the post-approval follow-up.
|
||||
|
||||
Inline approval buttons also depend on `channels.telegram.capabilities.inlineButtons` allowing the target surface (`dm`, `group`, or `all`).
|
||||
|
||||
Related docs: [Exec approvals](/tools/exec-approvals)
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Troubleshooting
|
||||
@@ -859,10 +887,16 @@ Primary reference:
|
||||
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (group fields + topic-only `agentId`).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.agentId`: route this topic to a specific agent (overrides group-level and binding routing).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
||||
- top-level `bindings[]` with `type: "acp"` and canonical topic id `chatId:topic:topicId` in `match.peer.id`: persistent ACP topic binding fields (see [ACP Agents](/tools/acp-agents#channel-specific-settings)).
|
||||
- `channels.telegram.direct.<id>.topics.<threadId>.agentId`: route DM topics to a specific agent (same behavior as forum topics).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
||||
- top-level `bindings[]` with `type: "acp"` and canonical topic id `chatId:topic:topicId` in `match.peer.id`: persistent ACP topic binding fields (see [ACP Agents](/tools/acp-agents#channel-specific-settings)).
|
||||
- `channels.telegram.direct.<id>.topics.<threadId>.agentId`: route DM topics to a specific agent (same behavior as forum topics).
|
||||
- `channels.telegram.execApprovals.enabled`: enable Telegram as a chat-based exec approval client for this account.
|
||||
- `channels.telegram.execApprovals.approvers`: Telegram user IDs allowed to approve or deny exec requests. Required when exec approvals are enabled.
|
||||
- `channels.telegram.execApprovals.target`: `dm | channel | both` (default: `dm`). `channel` and `both` preserve the originating Telegram topic when present.
|
||||
- `channels.telegram.execApprovals.agentFilter`: optional agent ID filter for forwarded approval prompts.
|
||||
- `channels.telegram.execApprovals.sessionFilter`: optional session key filter (substring or regex) for forwarded approval prompts.
|
||||
- `channels.telegram.accounts.<account>.execApprovals`: per-account override for Telegram exec approval routing and approver authorization.
|
||||
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
|
||||
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
||||
- `channels.telegram.commands.nativeSkills`: enable/disable Telegram native skills commands.
|
||||
@@ -894,6 +928,7 @@ Telegram-specific high-signal fields:
|
||||
|
||||
- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*`
|
||||
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`, top-level `bindings[]` (`type: "acp"`)
|
||||
- exec approvals: `execApprovals`, `accounts.*.execApprovals`
|
||||
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`
|
||||
- threading/replies: `replyToMode`
|
||||
- streaming: `streaming` (preview), `blockStreaming`
|
||||
|
||||
@@ -13,6 +13,49 @@ Run the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) bridge t
|
||||
This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway
|
||||
over WebSocket. It keeps ACP sessions mapped to Gateway session keys.
|
||||
|
||||
`openclaw acp` is a Gateway-backed ACP bridge, not a full ACP-native editor
|
||||
runtime. It focuses on session routing, prompt delivery, and basic streaming
|
||||
updates.
|
||||
|
||||
## Compatibility Matrix
|
||||
|
||||
| ACP area | Status | Notes |
|
||||
| --------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. |
|
||||
| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. |
|
||||
| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key and replays stored user/assistant text history. Tool/system history is not reconstructed yet. |
|
||||
| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. |
|
||||
| Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. |
|
||||
| Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. |
|
||||
| Tool streaming | Partial | `tool_call` / `tool_call_update` events include raw I/O, text content, and best-effort file locations when Gateway tool args/results expose them. Embedded terminals and richer diff-native output are still not exposed. |
|
||||
| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. |
|
||||
| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. |
|
||||
| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. |
|
||||
| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. |
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- `loadSession` replays stored user and assistant text history, but it does not
|
||||
reconstruct historic tool calls, system notices, or richer ACP-native event
|
||||
types.
|
||||
- If multiple ACP clients share the same Gateway session key, event and cancel
|
||||
routing are best-effort rather than strictly isolated per client. Prefer the
|
||||
default isolated `acp:<uuid>` sessions when you need clean editor-local
|
||||
turns.
|
||||
- Gateway stop states are translated into ACP stop reasons, but that mapping is
|
||||
less expressive than a fully ACP-native runtime.
|
||||
- Initial session controls currently surface a focused subset of Gateway knobs:
|
||||
thought level, tool verbosity, reasoning, usage detail, and elevated
|
||||
actions. Model selection and exec-host controls are not yet exposed as ACP
|
||||
config options.
|
||||
- `session_info_update` and `usage_update` are derived from Gateway session
|
||||
snapshots, not live ACP-native runtime accounting. Usage is approximate,
|
||||
carries no cost data, and is only emitted when the Gateway marks total token
|
||||
data as fresh.
|
||||
- Tool follow-along data is best-effort. The bridge can surface file paths that
|
||||
appear in known tool args/results, but it does not yet emit ACP terminals or
|
||||
structured file diffs.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
@@ -96,6 +139,10 @@ Each ACP session maps to a single Gateway session key. One agent can have many
|
||||
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
|
||||
the key or label.
|
||||
|
||||
Per-session `mcpServers` are not supported in bridge mode. If an ACP client
|
||||
sends them during `newSession` or `loadSession`, the bridge returns a clear
|
||||
error instead of silently ignoring them.
|
||||
|
||||
## Use from `acpx` (Codex, Claude, other ACP clients)
|
||||
|
||||
If you want a coding agent such as Codex or Claude Code to talk to your
|
||||
|
||||
@@ -30,6 +30,12 @@ Note: retention/pruning is controlled in config:
|
||||
- `cron.sessionRetention` (default `24h`) prunes completed isolated run sessions.
|
||||
- `cron.runLog.maxBytes` + `cron.runLog.keepLines` prune `~/.openclaw/cron/runs/<jobId>.jsonl`.
|
||||
|
||||
Upgrade note: if you have older cron jobs from before the current delivery/store format, run
|
||||
`openclaw doctor --fix`. Doctor now normalizes legacy cron fields (`jobId`, `schedule.cron`,
|
||||
top-level delivery fields, payload `provider` delivery aliases) and migrates simple
|
||||
`notify: true` webhook fallback jobs to explicit webhook delivery when `cron.webhook` is
|
||||
configured.
|
||||
|
||||
## Common edits
|
||||
|
||||
Update delivery settings without changing the message:
|
||||
|
||||
@@ -28,6 +28,7 @@ Notes:
|
||||
- Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts.
|
||||
- `--fix` (alias for `--repair`) writes a backup to `~/.openclaw/openclaw.json.bak` and drops unknown config keys, listing each removal.
|
||||
- State integrity checks now detect orphan transcript files in the sessions directory and can archive them as `.deleted.<timestamp>` to reclaim space safely.
|
||||
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime.
|
||||
- Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing.
|
||||
- If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`).
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ cat ~/.openclaw/openclaw.json
|
||||
- Config normalization for legacy values.
|
||||
- OpenCode Zen provider override warnings (`models.providers.opencode`).
|
||||
- Legacy on-disk state migration (sessions/agent dir/WhatsApp auth).
|
||||
- Legacy cron store migration (`jobId`, `schedule.cron`, top-level delivery/payload fields, payload `provider`, simple `notify: true` webhook fallback jobs).
|
||||
- State integrity and permissions checks (sessions, transcripts, state dir).
|
||||
- Config file permission checks (chmod 600) when running locally.
|
||||
- Model auth health: checks OAuth expiry, can refresh expiring tokens, and reports auth-profile cooldown/disabled states.
|
||||
@@ -158,6 +159,25 @@ the legacy sessions + agent dir on startup so history/auth/models land in the
|
||||
per-agent path without a manual doctor run. WhatsApp auth is intentionally only
|
||||
migrated via `openclaw doctor`.
|
||||
|
||||
### 3b) Legacy cron store migrations
|
||||
|
||||
Doctor also checks the cron job store (`~/.openclaw/cron/jobs.json` by default,
|
||||
or `cron.store` when overridden) for old job shapes that the scheduler still
|
||||
accepts for compatibility.
|
||||
|
||||
Current cron cleanups include:
|
||||
|
||||
- `jobId` → `id`
|
||||
- `schedule.cron` → `schedule.expr`
|
||||
- top-level payload fields (`message`, `model`, `thinking`, ...) → `payload`
|
||||
- top-level delivery fields (`deliver`, `channel`, `to`, `provider`, ...) → `delivery`
|
||||
- payload `provider` delivery aliases → explicit `delivery.channel`
|
||||
- simple legacy `notify: true` webhook fallback jobs → explicit `delivery.mode="webhook"` with `delivery.to=cron.webhook`
|
||||
|
||||
Doctor only auto-migrates `notify: true` jobs when it can do so without
|
||||
changing behavior. If a job combines legacy notify fallback with an existing
|
||||
non-webhook delivery mode, doctor warns and leaves that job for manual review.
|
||||
|
||||
### 4) State integrity checks (session persistence, routing, and safety)
|
||||
|
||||
The state directory is the operational brainstem. If it vanishes, you lose
|
||||
|
||||
@@ -38,7 +38,8 @@ Examples of inactive surfaces:
|
||||
- Top-level channel credentials that no enabled account inherits.
|
||||
- Disabled tool/feature surfaces.
|
||||
- Web search provider-specific keys that are not selected by `tools.web.search.provider`.
|
||||
In auto mode (provider unset), provider-specific keys are also active for provider auto-detection.
|
||||
In auto mode (provider unset), keys are consulted by precedence for provider auto-detection until one resolves.
|
||||
After selection, non-selected provider keys are treated as inactive until selected.
|
||||
- `gateway.remote.token` / `gateway.remote.password` SecretRefs are active (when `gateway.remote.enabled` is not `false`) if one of these is true:
|
||||
- `gateway.mode=remote`
|
||||
- `gateway.remote.url` is configured
|
||||
|
||||
@@ -1489,10 +1489,16 @@ Set `cli.banner.taglineMode` in config:
|
||||
|
||||
### How do I enable web search and web fetch
|
||||
|
||||
`web_fetch` works without an API key. `web_search` requires a Brave Search API
|
||||
key. **Recommended:** run `openclaw configure --section web` to store it in
|
||||
`tools.web.search.apiKey`. Environment alternative: set `BRAVE_API_KEY` for the
|
||||
Gateway process.
|
||||
`web_fetch` works without an API key. `web_search` requires a key for your
|
||||
selected provider (Brave, Gemini, Grok, Kimi, or Perplexity).
|
||||
**Recommended:** run `openclaw configure --section web` and choose a provider.
|
||||
Environment alternatives:
|
||||
|
||||
- Brave: `BRAVE_API_KEY`
|
||||
- Gemini: `GEMINI_API_KEY`
|
||||
- Grok: `XAI_API_KEY`
|
||||
- Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
|
||||
- Perplexity: `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -1500,6 +1506,7 @@ Gateway process.
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
provider: "brave",
|
||||
apiKey: "BRAVE_API_KEY_HERE",
|
||||
maxResults: 5,
|
||||
},
|
||||
|
||||
@@ -71,11 +71,14 @@ Optional legacy controls:
|
||||
|
||||
**Via config:** run `openclaw configure --section web`. It stores the key in
|
||||
`~/.openclaw/openclaw.json` under `tools.web.search.perplexity.apiKey`.
|
||||
That field also accepts SecretRef objects.
|
||||
|
||||
**Via environment:** set `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
|
||||
in the Gateway process environment. For a gateway install, put it in
|
||||
`~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
|
||||
|
||||
If `provider: "perplexity"` is configured and the Perplexity key SecretRef is unresolved with no env fallback, startup/reload fails fast.
|
||||
|
||||
## Tool parameters
|
||||
|
||||
These parameters apply to the native Perplexity Search API path.
|
||||
|
||||
@@ -80,10 +80,10 @@ See [Memory](/concepts/memory).
|
||||
`web_search` uses API keys and may incur usage charges depending on your provider:
|
||||
|
||||
- **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
|
||||
- **Gemini (Google Search)**: `GEMINI_API_KEY`
|
||||
- **Grok (xAI)**: `XAI_API_KEY`
|
||||
- **Kimi (Moonshot)**: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
|
||||
- **Perplexity Search API**: `PERPLEXITY_API_KEY`
|
||||
- **Gemini (Google Search)**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey`
|
||||
- **Grok (xAI)**: `XAI_API_KEY` or `tools.web.search.grok.apiKey`
|
||||
- **Kimi (Moonshot)**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey`
|
||||
- **Perplexity Search API**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey`
|
||||
|
||||
**Brave Search free credit:** Each Brave plan includes $5/month in renewing
|
||||
free credit. The Search plan costs $5 per 1,000 requests, so the credit covers
|
||||
|
||||
@@ -31,6 +31,7 @@ Scope intent:
|
||||
- `talk.providers.*.apiKey`
|
||||
- `messages.tts.elevenlabs.apiKey`
|
||||
- `messages.tts.openai.apiKey`
|
||||
- `tools.web.fetch.firecrawl.apiKey`
|
||||
- `tools.web.search.apiKey`
|
||||
- `tools.web.search.gemini.apiKey`
|
||||
- `tools.web.search.grok.apiKey`
|
||||
@@ -102,7 +103,8 @@ Notes:
|
||||
- For SecretRef-managed model providers, generated `agents/*/agent/models.json` entries persist non-secret markers (not resolved secret values) for `apiKey`/header surfaces.
|
||||
- For web search:
|
||||
- In explicit provider mode (`tools.web.search.provider` set), only the selected provider key is active.
|
||||
- In auto mode (`tools.web.search.provider` unset), `tools.web.search.apiKey` and provider-specific keys are active.
|
||||
- In auto mode (`tools.web.search.provider` unset), only the first provider key that resolves by precedence is active.
|
||||
- In auto mode, non-selected provider refs are treated as inactive until selected.
|
||||
|
||||
## Unsupported credentials
|
||||
|
||||
|
||||
@@ -454,6 +454,13 @@
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "tools.web.fetch.firecrawl.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "tools.web.fetch.firecrawl.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "tools.web.search.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
|
||||
@@ -246,6 +246,46 @@ Interface details:
|
||||
- `streamTo` (optional): `"parent"` streams initial ACP run progress summaries back to the requester session as system events.
|
||||
- When available, accepted responses include `streamLogPath` pointing to a session-scoped JSONL log (`<sessionId>.acp-stream.jsonl`) you can tail for full relay history.
|
||||
|
||||
### Operator smoke test
|
||||
|
||||
Use this after a gateway deploy when you want a quick live check that ACP spawn
|
||||
is actually working end-to-end, not just passing unit tests.
|
||||
|
||||
Recommended gate:
|
||||
|
||||
1. Verify the deployed gateway version/commit on the target host.
|
||||
2. Confirm the deployed source includes the ACP lineage acceptance in
|
||||
`src/gateway/sessions-patch.ts` (`subagent:* or acp:* sessions`).
|
||||
3. Open a temporary ACPX bridge session to a live agent (for example
|
||||
`razor(main)` on `jpclawhq`).
|
||||
4. Ask that agent to call `sessions_spawn` with:
|
||||
- `runtime: "acp"`
|
||||
- `agentId: "codex"`
|
||||
- `mode: "run"`
|
||||
- task: `Reply with exactly LIVE-ACP-SPAWN-OK`
|
||||
5. Verify the agent reports:
|
||||
- `accepted=yes`
|
||||
- a real `childSessionKey`
|
||||
- no validator error
|
||||
6. Clean up the temporary ACPX bridge session.
|
||||
|
||||
Example prompt to the live agent:
|
||||
|
||||
```text
|
||||
Use the sessions_spawn tool now with runtime: "acp", agentId: "codex", and mode: "run".
|
||||
Set the task to: "Reply with exactly LIVE-ACP-SPAWN-OK".
|
||||
Then report only: accepted=<yes/no>; childSessionKey=<value or none>; error=<exact text or none>.
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Keep this smoke test on `mode: "run"` unless you are intentionally testing
|
||||
thread-bound persistent ACP sessions.
|
||||
- Do not require `streamTo: "parent"` for the basic gate. That path depends on
|
||||
requester/session capabilities and is a separate integration check.
|
||||
- Treat thread-bound `mode: "session"` testing as a second, richer integration
|
||||
pass from a real Discord thread or Telegram topic.
|
||||
|
||||
## Sandbox compatibility
|
||||
|
||||
ACP sessions currently run on the host runtime, not inside the OpenClaw sandbox.
|
||||
|
||||
@@ -309,6 +309,32 @@ Reply in chat:
|
||||
/approve <id> deny
|
||||
```
|
||||
|
||||
### Built-in chat approval clients
|
||||
|
||||
Discord and Telegram can also act as explicit exec approval clients with channel-specific config.
|
||||
|
||||
- Discord: `channels.discord.execApprovals.*`
|
||||
- Telegram: `channels.telegram.execApprovals.*`
|
||||
|
||||
These clients are opt-in. If a channel does not have exec approvals enabled, OpenClaw does not treat
|
||||
that channel as an approval surface just because the conversation happened there.
|
||||
|
||||
Shared behavior:
|
||||
|
||||
- only configured approvers can approve or deny
|
||||
- the requester does not need to be an approver
|
||||
- when channel delivery is enabled, approval prompts include the command text
|
||||
- if no operator UI or configured approval client can accept the request, the prompt falls back to `askFallback`
|
||||
|
||||
Telegram defaults to approver DMs (`target: "dm"`). You can switch to `channel` or `both` when you
|
||||
want approval prompts to appear in the originating Telegram chat/topic as well. For Telegram forum
|
||||
topics, OpenClaw preserves the topic for the approval prompt and the post-approval follow-up.
|
||||
|
||||
See:
|
||||
|
||||
- [Discord](/channels/discord#exec-approvals-in-discord)
|
||||
- [Telegram](/channels/telegram#exec-approvals-in-telegram)
|
||||
|
||||
### macOS IPC flow
|
||||
|
||||
```
|
||||
|
||||
@@ -40,7 +40,8 @@ with JS-heavy sites or pages that block plain HTTP fetches.
|
||||
|
||||
Notes:
|
||||
|
||||
- `firecrawl.enabled` defaults to true when an API key is present.
|
||||
- `firecrawl.enabled` defaults to `true` unless explicitly set to `false`.
|
||||
- Firecrawl fallback attempts run only when an API key is available (`tools.web.fetch.firecrawl.apiKey` or `FIRECRAWL_API_KEY`).
|
||||
- `maxAgeMs` controls how old cached results can be (ms). Default is 2 days.
|
||||
|
||||
## Stealth / bot circumvention
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
summary: "Web search + fetch tools (Brave, Gemini, Grok, Kimi, and Perplexity providers)"
|
||||
read_when:
|
||||
- You want to enable web_search or web_fetch
|
||||
- You need Brave or Perplexity Search API key setup
|
||||
- You need provider API key setup
|
||||
- You want to use Gemini with Google Search grounding
|
||||
title: "Web Tools"
|
||||
---
|
||||
@@ -49,6 +49,12 @@ The table above is alphabetical. If no `provider` is explicitly set, runtime aut
|
||||
|
||||
If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one).
|
||||
|
||||
Runtime SecretRef behavior:
|
||||
|
||||
- Web tool SecretRefs are resolved atomically at gateway startup/reload.
|
||||
- In auto-detect mode, OpenClaw resolves only the selected provider key. Non-selected provider SecretRefs stay inactive until selected.
|
||||
- If the selected provider SecretRef is unresolved and no provider env fallback exists, startup/reload fails fast.
|
||||
|
||||
## Setting up web search
|
||||
|
||||
Use `openclaw configure --section web` to set up your API key and choose a provider.
|
||||
@@ -77,9 +83,25 @@ See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quicks
|
||||
|
||||
### Where to store the key
|
||||
|
||||
**Via config:** run `openclaw configure --section web`. It stores the key under `tools.web.search.apiKey` or `tools.web.search.perplexity.apiKey`, depending on provider.
|
||||
**Via config:** run `openclaw configure --section web`. It stores the key under the provider-specific config path:
|
||||
|
||||
**Via environment:** set `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `BRAVE_API_KEY` in the Gateway process environment. For a gateway install, put it in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
|
||||
- Brave: `tools.web.search.apiKey`
|
||||
- Gemini: `tools.web.search.gemini.apiKey`
|
||||
- Grok: `tools.web.search.grok.apiKey`
|
||||
- Kimi: `tools.web.search.kimi.apiKey`
|
||||
- Perplexity: `tools.web.search.perplexity.apiKey`
|
||||
|
||||
All of these fields also support SecretRef objects.
|
||||
|
||||
**Via environment:** set provider env vars in the Gateway process environment:
|
||||
|
||||
- Brave: `BRAVE_API_KEY`
|
||||
- Gemini: `GEMINI_API_KEY`
|
||||
- Grok: `XAI_API_KEY`
|
||||
- Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
|
||||
- Perplexity: `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
|
||||
|
||||
For a gateway install, put these in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
|
||||
|
||||
### Config examples
|
||||
|
||||
@@ -216,6 +238,7 @@ Search the web using your configured provider.
|
||||
- **Grok**: `XAI_API_KEY` or `tools.web.search.grok.apiKey`
|
||||
- **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey`
|
||||
- **Perplexity**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey`
|
||||
- All provider key fields above support SecretRef objects.
|
||||
|
||||
### Config
|
||||
|
||||
@@ -310,6 +333,7 @@ Fetch a URL and extract readable content.
|
||||
|
||||
- `tools.web.fetch.enabled` must not be `false` (default: enabled)
|
||||
- Optional Firecrawl fallback: set `tools.web.fetch.firecrawl.apiKey` or `FIRECRAWL_API_KEY`.
|
||||
- `tools.web.fetch.firecrawl.apiKey` supports SecretRef objects.
|
||||
|
||||
### web_fetch config
|
||||
|
||||
@@ -351,6 +375,8 @@ Notes:
|
||||
|
||||
- `web_fetch` uses Readability (main-content extraction) first, then Firecrawl (if configured). If both fail, the tool returns an error.
|
||||
- Firecrawl requests use bot-circumvention mode and cache results by default.
|
||||
- Firecrawl SecretRefs are resolved only when Firecrawl is active (`tools.web.fetch.enabled !== false` and `tools.web.fetch.firecrawl.enabled !== false`).
|
||||
- If Firecrawl is active and its SecretRef is unresolved with no `FIRECRAWL_API_KEY` fallback, startup/reload fails fast.
|
||||
- `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed.
|
||||
- `web_fetch` blocks private/internal hostnames and re-checks redirects (limit with `maxRedirects`).
|
||||
- `maxChars` is clamped to `tools.web.fetch.maxCharsCap`.
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
},
|
||||
"expectedVersion": {
|
||||
"label": "Expected acpx Version",
|
||||
"help": "Exact version to enforce (for example 0.1.15) or \"any\" to skip strict version matching."
|
||||
"help": "Exact version to enforce (for example 0.1.16) or \"any\" to skip strict version matching."
|
||||
},
|
||||
"cwd": {
|
||||
"label": "Default Working Directory",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "OpenClaw ACP runtime backend via acpx",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"acpx": "0.1.15"
|
||||
"acpx": "0.1.16"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
|
||||
@@ -8,7 +8,7 @@ export type AcpxPermissionMode = (typeof ACPX_PERMISSION_MODES)[number];
|
||||
export const ACPX_NON_INTERACTIVE_POLICIES = ["deny", "fail"] as const;
|
||||
export type AcpxNonInteractivePermissionPolicy = (typeof ACPX_NON_INTERACTIVE_POLICIES)[number];
|
||||
|
||||
export const ACPX_PINNED_VERSION = "0.1.15";
|
||||
export const ACPX_PINNED_VERSION = "0.1.16";
|
||||
export const ACPX_VERSION_ANY = "any";
|
||||
const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx";
|
||||
export const ACPX_PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
@@ -127,6 +127,65 @@ describe("AcpxRuntime", () => {
|
||||
expect(promptArgs).toContain("--approve-all");
|
||||
});
|
||||
|
||||
it("uses sessions new with --resume-session when resumeSessionId is provided", async () => {
|
||||
const { runtime, logPath } = await createMockRuntimeFixture();
|
||||
const resumeSessionId = "sid-resume-123";
|
||||
const sessionKey = "agent:codex:acp:resume";
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey,
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
resumeSessionId,
|
||||
});
|
||||
|
||||
expect(handle.backend).toBe("acpx");
|
||||
expect(handle.acpxRecordId).toBe("rec-" + sessionKey);
|
||||
|
||||
const logs = await readMockRuntimeLogEntries(logPath);
|
||||
expect(logs.some((entry) => entry.kind === "ensure")).toBe(false);
|
||||
const resumeEntry = logs.find(
|
||||
(entry) => entry.kind === "new" && String(entry.sessionName ?? "") === sessionKey,
|
||||
);
|
||||
expect(resumeEntry).toBeDefined();
|
||||
const resumeArgs = (resumeEntry?.args as string[]) ?? [];
|
||||
const resumeFlagIndex = resumeArgs.indexOf("--resume-session");
|
||||
expect(resumeFlagIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(resumeArgs[resumeFlagIndex + 1]).toBe(resumeSessionId);
|
||||
});
|
||||
|
||||
it("serializes text plus image attachments into ACP prompt blocks", async () => {
|
||||
const { runtime, logPath } = await createMockRuntimeFixture();
|
||||
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:with-image",
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
|
||||
for await (const _event of runtime.runTurn({
|
||||
handle,
|
||||
text: "describe this image",
|
||||
attachments: [{ mediaType: "image/png", data: "aW1hZ2UtYnl0ZXM=" }],
|
||||
mode: "prompt",
|
||||
requestId: "req-image",
|
||||
})) {
|
||||
// Consume stream to completion so prompt logging is finalized.
|
||||
}
|
||||
|
||||
const logs = await readMockRuntimeLogEntries(logPath);
|
||||
const prompt = logs.find(
|
||||
(entry) =>
|
||||
entry.kind === "prompt" && String(entry.sessionName ?? "") === "agent:codex:acp:with-image",
|
||||
);
|
||||
expect(prompt).toBeDefined();
|
||||
|
||||
const stdinBlocks = JSON.parse(String(prompt?.stdinText ?? ""));
|
||||
expect(stdinBlocks).toEqual([
|
||||
{ type: "text", text: "describe this image" },
|
||||
{ type: "image", mimeType: "image/png", data: "aW1hZ2UtYnl0ZXM=" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves leading spaces across streamed text deltas", async () => {
|
||||
const runtime = sharedFixture?.runtime;
|
||||
expect(runtime).toBeDefined();
|
||||
|
||||
@@ -203,10 +203,14 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
}
|
||||
const cwd = asTrimmedString(input.cwd) || this.config.cwd;
|
||||
const mode = input.mode;
|
||||
const resumeSessionId = asTrimmedString(input.resumeSessionId);
|
||||
const ensureSubcommand = resumeSessionId
|
||||
? ["sessions", "new", "--name", sessionName, "--resume-session", resumeSessionId]
|
||||
: ["sessions", "ensure", "--name", sessionName];
|
||||
const ensureCommand = await this.buildVerbArgs({
|
||||
agent,
|
||||
cwd,
|
||||
command: ["sessions", "ensure", "--name", sessionName],
|
||||
command: ensureSubcommand,
|
||||
});
|
||||
|
||||
let events = await this.runControlCommand({
|
||||
@@ -221,7 +225,7 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
asOptionalString(event.acpxRecordId),
|
||||
);
|
||||
|
||||
if (!ensuredEvent) {
|
||||
if (!ensuredEvent && !resumeSessionId) {
|
||||
const newCommand = await this.buildVerbArgs({
|
||||
agent,
|
||||
cwd,
|
||||
@@ -238,12 +242,14 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
asOptionalString(event.acpxSessionId) ||
|
||||
asOptionalString(event.acpxRecordId),
|
||||
);
|
||||
if (!ensuredEvent) {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`ACP session init failed: neither 'sessions ensure' nor 'sessions new' returned valid session identifiers for ${sessionName}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!ensuredEvent) {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
resumeSessionId
|
||||
? `ACP session init failed: 'sessions new --resume-session' returned no session identifiers for ${sessionName}.`
|
||||
: `ACP session init failed: neither 'sessions ensure' nor 'sessions new' returned valid session identifiers for ${sessionName}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const acpxRecordId = ensuredEvent ? asOptionalString(ensuredEvent.acpxRecordId) : undefined;
|
||||
@@ -310,7 +316,20 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
// Ignore EPIPE when the child exits before stdin flush completes.
|
||||
});
|
||||
|
||||
child.stdin.end(input.text);
|
||||
if (input.attachments && input.attachments.length > 0) {
|
||||
const blocks: unknown[] = [];
|
||||
if (input.text) {
|
||||
blocks.push({ type: "text", text: input.text });
|
||||
}
|
||||
for (const attachment of input.attachments) {
|
||||
if (attachment.mediaType.startsWith("image/")) {
|
||||
blocks.push({ type: "image", mimeType: attachment.mediaType, data: attachment.data });
|
||||
}
|
||||
}
|
||||
child.stdin.end(blocks.length > 0 ? JSON.stringify(blocks) : input.text);
|
||||
} else {
|
||||
child.stdin.end(input.text);
|
||||
}
|
||||
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
|
||||
@@ -52,6 +52,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
|
||||
to: "chat_1",
|
||||
text: file,
|
||||
accountId: "main",
|
||||
mediaLocalRoots: [dir],
|
||||
});
|
||||
|
||||
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
||||
@@ -59,6 +60,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
|
||||
to: "chat_1",
|
||||
mediaUrl: file,
|
||||
accountId: "main",
|
||||
mediaLocalRoots: [dir],
|
||||
}),
|
||||
);
|
||||
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||
|
||||
@@ -81,7 +81,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId, threadId, mediaLocalRoots }) => {
|
||||
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
|
||||
// Scheme A compatibility shim:
|
||||
// when upstream accidentally returns a local image path as plain text,
|
||||
@@ -95,6 +95,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
mediaUrl: localImagePath,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
mediaLocalRoots,
|
||||
});
|
||||
return { channel: "feishu", ...result };
|
||||
} catch (err) {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"google-auth-library": "^10.6.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.2"
|
||||
"openclaw": ">=2026.3.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -214,6 +214,57 @@ describe("mattermostPlugin", () => {
|
||||
]);
|
||||
expect(result?.details).toEqual({});
|
||||
});
|
||||
|
||||
it("maps replyTo to replyToId for send actions", async () => {
|
||||
const cfg = createMattermostTestConfig();
|
||||
|
||||
await mattermostPlugin.actions?.handleAction?.({
|
||||
channel: "mattermost",
|
||||
action: "send",
|
||||
params: {
|
||||
to: "channel:CHAN1",
|
||||
message: "hello",
|
||||
replyTo: "post-root",
|
||||
},
|
||||
cfg,
|
||||
accountId: "default",
|
||||
} as any);
|
||||
|
||||
expect(sendMessageMattermostMock).toHaveBeenCalledWith(
|
||||
"channel:CHAN1",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
replyToId: "post-root",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to trimmed replyTo when replyToId is blank", async () => {
|
||||
const cfg = createMattermostTestConfig();
|
||||
|
||||
await mattermostPlugin.actions?.handleAction?.({
|
||||
channel: "mattermost",
|
||||
action: "send",
|
||||
params: {
|
||||
to: "channel:CHAN1",
|
||||
message: "hello",
|
||||
replyToId: " ",
|
||||
replyTo: " post-root ",
|
||||
},
|
||||
cfg,
|
||||
accountId: "default",
|
||||
} as any);
|
||||
|
||||
expect(sendMessageMattermostMock).toHaveBeenCalledWith(
|
||||
"channel:CHAN1",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
replyToId: "post-root",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("outbound", () => {
|
||||
|
||||
@@ -35,6 +35,7 @@ import { monitorMattermostProvider } from "./mattermost/monitor.js";
|
||||
import { probeMattermost } from "./mattermost/probe.js";
|
||||
import { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js";
|
||||
import { sendMessageMattermost } from "./mattermost/send.js";
|
||||
import { resolveMattermostOpaqueTarget } from "./mattermost/target-resolution.js";
|
||||
import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js";
|
||||
import { mattermostOnboardingAdapter } from "./onboarding.js";
|
||||
import { getMattermostRuntime } from "./runtime.js";
|
||||
@@ -157,7 +158,9 @@ const mattermostMessageActions: ChannelMessageActionAdapter = {
|
||||
}
|
||||
|
||||
const message = typeof params.message === "string" ? params.message : "";
|
||||
const replyToId = typeof params.replyToId === "string" ? params.replyToId : undefined;
|
||||
// Match the shared runner semantics: trim empty reply IDs away before
|
||||
// falling back from replyToId to replyTo on direct plugin calls.
|
||||
const replyToId = readMattermostReplyToId(params);
|
||||
const resolvedAccountId = accountId || undefined;
|
||||
|
||||
const mediaUrl =
|
||||
@@ -201,6 +204,18 @@ const meta = {
|
||||
quickstartAllowFrom: true,
|
||||
} as const;
|
||||
|
||||
function readMattermostReplyToId(params: Record<string, unknown>): string | undefined {
|
||||
const readNormalizedValue = (value: unknown) => {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
};
|
||||
|
||||
return readNormalizedValue(params.replyToId) ?? readNormalizedValue(params.replyTo);
|
||||
}
|
||||
|
||||
function normalizeAllowEntry(entry: string): string {
|
||||
return entry
|
||||
.trim()
|
||||
@@ -326,6 +341,21 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeMattermostTargetId,
|
||||
hint: "<channelId|user:ID|channel:ID>",
|
||||
resolveTarget: async ({ cfg, accountId, input }) => {
|
||||
const resolved = await resolveMattermostOpaqueTarget({
|
||||
input,
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to: resolved.to,
|
||||
kind: resolved.kind,
|
||||
source: "directory",
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
|
||||
82
extensions/mattermost/src/mattermost/monitor-helpers.test.ts
Normal file
82
extensions/mattermost/src/mattermost/monitor-helpers.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeMention } from "./monitor-helpers.js";
|
||||
|
||||
describe("normalizeMention", () => {
|
||||
it("returns trimmed text when no mention provided", () => {
|
||||
expect(normalizeMention(" hello world ", undefined)).toBe("hello world");
|
||||
});
|
||||
|
||||
it("strips bot mention from text", () => {
|
||||
expect(normalizeMention("@echobot hello", "echobot")).toBe("hello");
|
||||
});
|
||||
|
||||
it("strips mention case-insensitively", () => {
|
||||
expect(normalizeMention("@EchoBot hello", "echobot")).toBe("hello");
|
||||
});
|
||||
|
||||
it("preserves newlines in multi-line messages", () => {
|
||||
const input = "@echobot\nline1\nline2\nline3";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toBe("line1\nline2\nline3");
|
||||
});
|
||||
|
||||
it("preserves Markdown headings", () => {
|
||||
const input = "@echobot\n# Heading\n\nSome text";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toContain("# Heading");
|
||||
expect(result).toContain("\n");
|
||||
});
|
||||
|
||||
it("preserves Markdown blockquotes", () => {
|
||||
const input = "@echobot\n> quoted line\n> second line";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toContain("> quoted line");
|
||||
expect(result).toContain("> second line");
|
||||
});
|
||||
|
||||
it("preserves Markdown lists", () => {
|
||||
const input = "@echobot\n- item A\n- item B\n - sub B1";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toContain("- item A");
|
||||
expect(result).toContain("- item B");
|
||||
});
|
||||
|
||||
it("preserves task lists", () => {
|
||||
const input = "@echobot\n- [ ] todo\n- [x] done";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toContain("- [ ] todo");
|
||||
expect(result).toContain("- [x] done");
|
||||
});
|
||||
|
||||
it("handles mention in middle of text", () => {
|
||||
const input = "hey @echobot check this\nout";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toBe("hey check this\nout");
|
||||
});
|
||||
|
||||
it("preserves leading indentation for nested lists", () => {
|
||||
const input = "@echobot\n- item\n - nested\n - deep";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toContain(" - nested");
|
||||
expect(result).toContain(" - deep");
|
||||
});
|
||||
|
||||
it("preserves first-line indentation for nested list items", () => {
|
||||
const input = "@echobot\n - nested\n - deep";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toBe(" - nested\n - deep");
|
||||
});
|
||||
|
||||
it("preserves indented code blocks", () => {
|
||||
const input = "@echobot\ntext\n code line 1\n code line 2";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toContain(" code line 1");
|
||||
expect(result).toContain(" code line 2");
|
||||
});
|
||||
|
||||
it("preserves first-line indentation for indented code blocks", () => {
|
||||
const input = "@echobot\n code line 1\n code line 2";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toBe(" code line 1\n code line 2");
|
||||
});
|
||||
});
|
||||
@@ -70,3 +70,38 @@ export function resolveThreadSessionKeys(params: {
|
||||
normalizeThreadId: (threadId) => threadId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip bot mention from message text while preserving newlines and
|
||||
* block-level Markdown formatting (headings, lists, blockquotes).
|
||||
*/
|
||||
export function normalizeMention(text: string, mention: string | undefined): string {
|
||||
if (!mention) {
|
||||
return text.trim();
|
||||
}
|
||||
const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const hasMentionRe = new RegExp(`@${escaped}\\b`, "i");
|
||||
const leadingMentionRe = new RegExp(`^([\\t ]*)@${escaped}\\b[\\t ]*`, "i");
|
||||
const trailingMentionRe = new RegExp(`[\\t ]*@${escaped}\\b[\\t ]*$`, "i");
|
||||
const normalizedLines = text.split("\n").map((line) => {
|
||||
const hadMention = hasMentionRe.test(line);
|
||||
const normalizedLine = line
|
||||
.replace(leadingMentionRe, "$1")
|
||||
.replace(trailingMentionRe, "")
|
||||
.replace(new RegExp(`@${escaped}\\b`, "gi"), "")
|
||||
.replace(/(\S)[ \t]{2,}/g, "$1 ");
|
||||
return {
|
||||
text: normalizedLine,
|
||||
mentionOnlyBlank: hadMention && normalizedLine.trim() === "",
|
||||
};
|
||||
});
|
||||
|
||||
while (normalizedLines[0]?.mentionOnlyBlank) {
|
||||
normalizedLines.shift();
|
||||
}
|
||||
while (normalizedLines.at(-1)?.text.trim() === "") {
|
||||
normalizedLines.pop();
|
||||
}
|
||||
|
||||
return normalizedLines.map((line) => line.text).join("\n");
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
import {
|
||||
createDedupeCache,
|
||||
formatInboundFromLabel,
|
||||
normalizeMention,
|
||||
resolveThreadSessionKeys,
|
||||
} from "./monitor-helpers.js";
|
||||
import { resolveOncharPrefixes, stripOncharPrefix } from "./monitor-onchar.js";
|
||||
@@ -143,15 +144,6 @@ function resolveRuntime(opts: MonitorMattermostOpts): RuntimeEnv {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMention(text: string, mention: string | undefined): string {
|
||||
if (!mention) {
|
||||
return text.trim();
|
||||
}
|
||||
const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const re = new RegExp(`@${escaped}\\b`, "gi");
|
||||
return text.replace(re, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function isSystemPost(post: MattermostPost): boolean {
|
||||
const type = post.type?.trim();
|
||||
return Boolean(type);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { parseMattermostTarget, sendMessageMattermost } from "./send.js";
|
||||
import { resetMattermostOpaqueTargetCacheForTests } from "./target-resolution.js";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
@@ -14,6 +15,7 @@ const mockState = vi.hoisted(() => ({
|
||||
createMattermostPost: vi.fn(),
|
||||
fetchMattermostChannelByName: vi.fn(),
|
||||
fetchMattermostMe: vi.fn(),
|
||||
fetchMattermostUser: vi.fn(),
|
||||
fetchMattermostUserTeams: vi.fn(),
|
||||
fetchMattermostUserByUsername: vi.fn(),
|
||||
normalizeMattermostBaseUrl: vi.fn((input: string | undefined) => input?.trim() ?? ""),
|
||||
@@ -34,6 +36,7 @@ vi.mock("./client.js", () => ({
|
||||
createMattermostPost: mockState.createMattermostPost,
|
||||
fetchMattermostChannelByName: mockState.fetchMattermostChannelByName,
|
||||
fetchMattermostMe: mockState.fetchMattermostMe,
|
||||
fetchMattermostUser: mockState.fetchMattermostUser,
|
||||
fetchMattermostUserTeams: mockState.fetchMattermostUserTeams,
|
||||
fetchMattermostUserByUsername: mockState.fetchMattermostUserByUsername,
|
||||
normalizeMattermostBaseUrl: mockState.normalizeMattermostBaseUrl,
|
||||
@@ -77,9 +80,11 @@ describe("sendMessageMattermost", () => {
|
||||
mockState.createMattermostPost.mockReset();
|
||||
mockState.fetchMattermostChannelByName.mockReset();
|
||||
mockState.fetchMattermostMe.mockReset();
|
||||
mockState.fetchMattermostUser.mockReset();
|
||||
mockState.fetchMattermostUserTeams.mockReset();
|
||||
mockState.fetchMattermostUserByUsername.mockReset();
|
||||
mockState.uploadMattermostFile.mockReset();
|
||||
resetMattermostOpaqueTargetCacheForTests();
|
||||
mockState.createMattermostClient.mockReturnValue({});
|
||||
mockState.createMattermostPost.mockResolvedValue({ id: "post-1" });
|
||||
mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-user" });
|
||||
@@ -182,6 +187,61 @@ describe("sendMessageMattermost", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a bare Mattermost user id as a DM target before upload", async () => {
|
||||
const userId = "dthcxgoxhifn3pwh65cut3ud3w";
|
||||
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
|
||||
mockState.createMattermostDirectChannel.mockResolvedValueOnce({ id: "dm-channel-1" });
|
||||
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("media-bytes"),
|
||||
fileName: "photo.png",
|
||||
contentType: "image/png",
|
||||
kind: "image",
|
||||
});
|
||||
|
||||
const result = await sendMessageMattermost(userId, "hello", {
|
||||
mediaUrl: "file:///tmp/agent-workspace/photo.png",
|
||||
mediaLocalRoots: ["/tmp/agent-workspace"],
|
||||
});
|
||||
|
||||
expect(mockState.fetchMattermostUser).toHaveBeenCalledWith({}, userId);
|
||||
expect(mockState.createMattermostDirectChannel).toHaveBeenCalledWith({}, ["bot-user", userId]);
|
||||
expect(mockState.uploadMattermostFile).toHaveBeenCalledWith(
|
||||
{},
|
||||
expect.objectContaining({
|
||||
channelId: "dm-channel-1",
|
||||
}),
|
||||
);
|
||||
expect(result.channelId).toBe("dm-channel-1");
|
||||
});
|
||||
|
||||
it("falls back to a channel target when bare Mattermost id is not a user", async () => {
|
||||
const channelId = "aaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
mockState.fetchMattermostUser.mockRejectedValueOnce(
|
||||
new Error("Mattermost API 404 Not Found: user not found"),
|
||||
);
|
||||
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("media-bytes"),
|
||||
fileName: "photo.png",
|
||||
contentType: "image/png",
|
||||
kind: "image",
|
||||
});
|
||||
|
||||
const result = await sendMessageMattermost(channelId, "hello", {
|
||||
mediaUrl: "file:///tmp/agent-workspace/photo.png",
|
||||
mediaLocalRoots: ["/tmp/agent-workspace"],
|
||||
});
|
||||
|
||||
expect(mockState.fetchMattermostUser).toHaveBeenCalledWith({}, channelId);
|
||||
expect(mockState.createMattermostDirectChannel).not.toHaveBeenCalled();
|
||||
expect(mockState.uploadMattermostFile).toHaveBeenCalledWith(
|
||||
{},
|
||||
expect.objectContaining({
|
||||
channelId,
|
||||
}),
|
||||
);
|
||||
expect(result.channelId).toBe(channelId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMattermostTarget", () => {
|
||||
@@ -266,3 +326,110 @@ describe("parseMattermostTarget", () => {
|
||||
expect(parseMattermostTarget("Mattermost:QRS")).toEqual({ kind: "user", id: "QRS" });
|
||||
});
|
||||
});
|
||||
|
||||
// Each test uses a unique (token, id) pair to avoid module-level cache collisions.
|
||||
// userIdResolutionCache and dmChannelCache are module singletons that survive across tests.
|
||||
// Using unique cache keys per test ensures full isolation without needing a cache reset API.
|
||||
describe("sendMessageMattermost user-first resolution", () => {
|
||||
function makeAccount(token: string) {
|
||||
return {
|
||||
accountId: "default",
|
||||
botToken: token,
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.createMattermostClient.mockReturnValue({});
|
||||
mockState.createMattermostPost.mockResolvedValue({ id: "post-id" });
|
||||
mockState.createMattermostDirectChannel.mockResolvedValue({ id: "dm-channel-id" });
|
||||
mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-id" });
|
||||
});
|
||||
|
||||
it("resolves unprefixed 26-char id as user and sends via DM channel", async () => {
|
||||
// Unique token + id to avoid cache pollution from other tests
|
||||
const userId = "aaaaaa1111111111aaaaaa1111"; // 26 chars
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-user-dm-t1"));
|
||||
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
|
||||
|
||||
const res = await sendMessageMattermost(userId, "hello");
|
||||
|
||||
expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.createMattermostDirectChannel).toHaveBeenCalledTimes(1);
|
||||
const params = mockState.createMattermostPost.mock.calls[0]?.[1];
|
||||
expect(params.channelId).toBe("dm-channel-id");
|
||||
expect(res.channelId).toBe("dm-channel-id");
|
||||
expect(res.messageId).toBe("post-id");
|
||||
});
|
||||
|
||||
it("falls back to channel id when user lookup returns 404", async () => {
|
||||
// Unique token + id for this test
|
||||
const channelId = "bbbbbb2222222222bbbbbb2222"; // 26 chars
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-404-t2"));
|
||||
const err = new Error("Mattermost API 404: user not found");
|
||||
mockState.fetchMattermostUser.mockRejectedValueOnce(err);
|
||||
|
||||
const res = await sendMessageMattermost(channelId, "hello");
|
||||
|
||||
expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.createMattermostDirectChannel).not.toHaveBeenCalled();
|
||||
const params = mockState.createMattermostPost.mock.calls[0]?.[1];
|
||||
expect(params.channelId).toBe(channelId);
|
||||
expect(res.channelId).toBe(channelId);
|
||||
});
|
||||
|
||||
it("falls back to channel id without caching negative result on transient error", async () => {
|
||||
// Two unique tokens so each call has its own cache namespace
|
||||
const userId = "cccccc3333333333cccccc3333"; // 26 chars
|
||||
const tokenA = "token-transient-t3a";
|
||||
const tokenB = "token-transient-t3b";
|
||||
const transientErr = new Error("Mattermost API 503: service unavailable");
|
||||
|
||||
// First call: transient error → fall back to channel id, do NOT cache negative
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount(tokenA));
|
||||
mockState.fetchMattermostUser.mockRejectedValueOnce(transientErr);
|
||||
|
||||
const res1 = await sendMessageMattermost(userId, "first");
|
||||
expect(res1.channelId).toBe(userId);
|
||||
|
||||
// Second call with a different token (new cache key) → retries user lookup
|
||||
vi.clearAllMocks();
|
||||
mockState.createMattermostClient.mockReturnValue({});
|
||||
mockState.createMattermostPost.mockResolvedValue({ id: "post-id-2" });
|
||||
mockState.createMattermostDirectChannel.mockResolvedValue({ id: "dm-channel-id" });
|
||||
mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-id" });
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount(tokenB));
|
||||
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
|
||||
|
||||
const res2 = await sendMessageMattermost(userId, "second");
|
||||
expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1);
|
||||
expect(res2.channelId).toBe("dm-channel-id");
|
||||
});
|
||||
|
||||
it("does not apply user-first resolution for explicit user: prefix", async () => {
|
||||
// Unique token + id — explicit user: prefix bypasses probe, goes straight to DM
|
||||
const userId = "dddddd4444444444dddddd4444"; // 26 chars
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-explicit-user-t4"));
|
||||
|
||||
const res = await sendMessageMattermost(`user:${userId}`, "hello");
|
||||
|
||||
expect(mockState.fetchMattermostUser).not.toHaveBeenCalled();
|
||||
expect(mockState.createMattermostDirectChannel).toHaveBeenCalledTimes(1);
|
||||
expect(res.channelId).toBe("dm-channel-id");
|
||||
});
|
||||
|
||||
it("does not apply user-first resolution for explicit channel: prefix", async () => {
|
||||
// Unique token + id — explicit channel: prefix, no probe, no DM
|
||||
const chanId = "eeeeee5555555555eeeeee5555"; // 26 chars
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-explicit-chan-t5"));
|
||||
|
||||
const res = await sendMessageMattermost(`channel:${chanId}`, "hello");
|
||||
|
||||
expect(mockState.fetchMattermostUser).not.toHaveBeenCalled();
|
||||
expect(mockState.createMattermostDirectChannel).not.toHaveBeenCalled();
|
||||
const params = mockState.createMattermostPost.mock.calls[0]?.[1];
|
||||
expect(params.channelId).toBe(chanId);
|
||||
expect(res.channelId).toBe(chanId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
setInteractionSecret,
|
||||
type MattermostInteractiveButtonInput,
|
||||
} from "./interactions.js";
|
||||
import { isMattermostId, resolveMattermostOpaqueTarget } from "./target-resolution.js";
|
||||
|
||||
export type MattermostSendOpts = {
|
||||
cfg?: OpenClawConfig;
|
||||
@@ -50,6 +51,7 @@ type MattermostTarget =
|
||||
const botUserCache = new Map<string, MattermostUser>();
|
||||
const userByNameCache = new Map<string, MattermostUser>();
|
||||
const channelByNameCache = new Map<string, string>();
|
||||
const dmChannelCache = new Map<string, string>();
|
||||
|
||||
const getCore = () => getMattermostRuntime();
|
||||
|
||||
@@ -66,12 +68,6 @@ function normalizeMessage(text: string, mediaUrl?: string): string {
|
||||
function isHttpUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value);
|
||||
}
|
||||
|
||||
/** Mattermost IDs are 26-character lowercase alphanumeric strings. */
|
||||
function isMattermostId(value: string): boolean {
|
||||
return /^[a-z0-9]{26}$/.test(value);
|
||||
}
|
||||
|
||||
export function parseMattermostTarget(raw: string): MattermostTarget {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
@@ -208,12 +204,18 @@ async function resolveTargetChannelId(params: {
|
||||
token: params.token,
|
||||
username: params.target.username ?? "",
|
||||
});
|
||||
const dmKey = `${cacheKey(params.baseUrl, params.token)}::dm::${userId}`;
|
||||
const cachedDm = dmChannelCache.get(dmKey);
|
||||
if (cachedDm) {
|
||||
return cachedDm;
|
||||
}
|
||||
const botUser = await resolveBotUser(params.baseUrl, params.token);
|
||||
const client = createMattermostClient({
|
||||
baseUrl: params.baseUrl,
|
||||
botToken: params.token,
|
||||
});
|
||||
const channel = await createMattermostDirectChannel(client, [botUser.id, userId]);
|
||||
dmChannelCache.set(dmKey, channel.id);
|
||||
return channel.id;
|
||||
}
|
||||
|
||||
@@ -248,7 +250,18 @@ async function resolveMattermostSendContext(
|
||||
);
|
||||
}
|
||||
|
||||
const target = parseMattermostTarget(to);
|
||||
const trimmedTo = to?.trim() ?? "";
|
||||
const opaqueTarget = await resolveMattermostOpaqueTarget({
|
||||
input: trimmedTo,
|
||||
token,
|
||||
baseUrl,
|
||||
});
|
||||
const target =
|
||||
opaqueTarget?.kind === "user"
|
||||
? { kind: "user" as const, id: opaqueTarget.id }
|
||||
: opaqueTarget?.kind === "channel"
|
||||
? { kind: "channel" as const, id: opaqueTarget.id }
|
||||
: parseMattermostTarget(trimmedTo);
|
||||
const channelId = await resolveTargetChannelId({
|
||||
target,
|
||||
baseUrl,
|
||||
|
||||
97
extensions/mattermost/src/mattermost/target-resolution.ts
Normal file
97
extensions/mattermost/src/mattermost/target-resolution.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
fetchMattermostUser,
|
||||
normalizeMattermostBaseUrl,
|
||||
} from "./client.js";
|
||||
|
||||
export type MattermostOpaqueTargetResolution = {
|
||||
kind: "user" | "channel";
|
||||
id: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
const mattermostOpaqueTargetCache = new Map<string, boolean>();
|
||||
|
||||
function cacheKey(baseUrl: string, token: string, id: string): string {
|
||||
return `${baseUrl}::${token}::${id}`;
|
||||
}
|
||||
|
||||
/** Mattermost IDs are 26-character lowercase alphanumeric strings. */
|
||||
export function isMattermostId(value: string): boolean {
|
||||
return /^[a-z0-9]{26}$/.test(value);
|
||||
}
|
||||
|
||||
export function isExplicitMattermostTarget(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
/^(channel|user|mattermost):/i.test(trimmed) ||
|
||||
trimmed.startsWith("@") ||
|
||||
trimmed.startsWith("#")
|
||||
);
|
||||
}
|
||||
|
||||
export function parseMattermostApiStatus(err: unknown): number | undefined {
|
||||
if (!err || typeof err !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const msg = "message" in err ? String((err as { message?: unknown }).message ?? "") : "";
|
||||
const match = /Mattermost API (\d{3})\b/.exec(msg);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const code = Number(match[1]);
|
||||
return Number.isFinite(code) ? code : undefined;
|
||||
}
|
||||
|
||||
export async function resolveMattermostOpaqueTarget(params: {
|
||||
input: string;
|
||||
cfg?: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
token?: string;
|
||||
baseUrl?: string;
|
||||
}): Promise<MattermostOpaqueTargetResolution | null> {
|
||||
const input = params.input.trim();
|
||||
if (!input || isExplicitMattermostTarget(input) || !isMattermostId(input)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const account =
|
||||
params.cfg && (!params.token || !params.baseUrl)
|
||||
? resolveMattermostAccount({ cfg: params.cfg, accountId: params.accountId })
|
||||
: null;
|
||||
const token = params.token?.trim() || account?.botToken?.trim();
|
||||
const baseUrl = normalizeMattermostBaseUrl(params.baseUrl ?? account?.baseUrl);
|
||||
if (!token || !baseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = cacheKey(baseUrl, token, input);
|
||||
const cached = mattermostOpaqueTargetCache.get(key);
|
||||
if (cached === true) {
|
||||
return { kind: "user", id: input, to: `user:${input}` };
|
||||
}
|
||||
if (cached === false) {
|
||||
return { kind: "channel", id: input, to: `channel:${input}` };
|
||||
}
|
||||
|
||||
const client = createMattermostClient({ baseUrl, botToken: token });
|
||||
try {
|
||||
await fetchMattermostUser(client, input);
|
||||
mattermostOpaqueTargetCache.set(key, true);
|
||||
return { kind: "user", id: input, to: `user:${input}` };
|
||||
} catch (err) {
|
||||
if (parseMattermostApiStatus(err) === 404) {
|
||||
mattermostOpaqueTargetCache.set(key, false);
|
||||
}
|
||||
return { kind: "channel", id: input, to: `channel:${input}` };
|
||||
}
|
||||
}
|
||||
|
||||
export function resetMattermostOpaqueTargetCacheForTests(): void {
|
||||
mattermostOpaqueTargetCache.clear();
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "OpenClaw core memory search plugin",
|
||||
"type": "module",
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.2"
|
||||
"openclaw": ">=2026.3.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -54,10 +54,12 @@ describe("resolveMSTeamsUserAllowlist", () => {
|
||||
|
||||
describe("resolveMSTeamsChannelAllowlist", () => {
|
||||
it("resolves team/channel by team name + channel display name", async () => {
|
||||
listTeamsByName.mockResolvedValueOnce([{ id: "team-1", displayName: "Product Team" }]);
|
||||
// After the fix, listChannelsForTeam is called once and reused for both
|
||||
// General channel resolution and channel matching.
|
||||
listTeamsByName.mockResolvedValueOnce([{ id: "team-guid-1", displayName: "Product Team" }]);
|
||||
listChannelsForTeam.mockResolvedValueOnce([
|
||||
{ id: "channel-1", displayName: "General" },
|
||||
{ id: "channel-2", displayName: "Roadmap" },
|
||||
{ id: "19:general-conv-id@thread.tacv2", displayName: "General" },
|
||||
{ id: "19:roadmap-conv-id@thread.tacv2", displayName: "Roadmap" },
|
||||
]);
|
||||
|
||||
const [result] = await resolveMSTeamsChannelAllowlist({
|
||||
@@ -65,14 +67,80 @@ describe("resolveMSTeamsChannelAllowlist", () => {
|
||||
entries: ["Product Team/Roadmap"],
|
||||
});
|
||||
|
||||
// teamId is now the General channel's conversation ID — not the Graph GUID —
|
||||
// because that's what Bot Framework sends as channelData.team.id at runtime.
|
||||
expect(result).toEqual({
|
||||
input: "Product Team/Roadmap",
|
||||
resolved: true,
|
||||
teamId: "team-1",
|
||||
teamId: "19:general-conv-id@thread.tacv2",
|
||||
teamName: "Product Team",
|
||||
channelId: "channel-2",
|
||||
channelId: "19:roadmap-conv-id@thread.tacv2",
|
||||
channelName: "Roadmap",
|
||||
note: "multiple channels; chose first",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses General channel conversation ID as team key for team-only entry", async () => {
|
||||
// When no channel is specified we still resolve the General channel so the
|
||||
// stored key matches what Bot Framework sends as channelData.team.id.
|
||||
listTeamsByName.mockResolvedValueOnce([{ id: "guid-engineering", displayName: "Engineering" }]);
|
||||
listChannelsForTeam.mockResolvedValueOnce([
|
||||
{ id: "19:eng-general@thread.tacv2", displayName: "General" },
|
||||
{ id: "19:eng-standups@thread.tacv2", displayName: "Standups" },
|
||||
]);
|
||||
|
||||
const [result] = await resolveMSTeamsChannelAllowlist({
|
||||
cfg: {},
|
||||
entries: ["Engineering"],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
input: "Engineering",
|
||||
resolved: true,
|
||||
teamId: "19:eng-general@thread.tacv2",
|
||||
teamName: "Engineering",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to Graph GUID when listChannelsForTeam throws", async () => {
|
||||
// Edge case: API call fails (rate limit, network error). We fall back to
|
||||
// the Graph GUID as the team key — the pre-fix behavior — so resolution
|
||||
// still succeeds instead of propagating the error.
|
||||
listTeamsByName.mockResolvedValueOnce([{ id: "guid-flaky", displayName: "Flaky Team" }]);
|
||||
listChannelsForTeam.mockRejectedValueOnce(new Error("429 Too Many Requests"));
|
||||
|
||||
const [result] = await resolveMSTeamsChannelAllowlist({
|
||||
cfg: {},
|
||||
entries: ["Flaky Team"],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
input: "Flaky Team",
|
||||
resolved: true,
|
||||
teamId: "guid-flaky",
|
||||
teamName: "Flaky Team",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to Graph GUID when General channel is not found", async () => {
|
||||
// Edge case: General channel was renamed or deleted. We fall back to the
|
||||
// Graph GUID so resolution still succeeds rather than silently breaking.
|
||||
listTeamsByName.mockResolvedValueOnce([{ id: "guid-ops", displayName: "Operations" }]);
|
||||
listChannelsForTeam.mockResolvedValueOnce([
|
||||
{ id: "19:ops-announce@thread.tacv2", displayName: "Announcements" },
|
||||
{ id: "19:ops-random@thread.tacv2", displayName: "Random" },
|
||||
]);
|
||||
|
||||
const [result] = await resolveMSTeamsChannelAllowlist({
|
||||
cfg: {},
|
||||
entries: ["Operations"],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
input: "Operations",
|
||||
resolved: true,
|
||||
teamId: "guid-ops",
|
||||
teamName: "Operations",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,11 +120,26 @@ export async function resolveMSTeamsChannelAllowlist(params: {
|
||||
return { input, resolved: false, note: "team not found" };
|
||||
}
|
||||
const teamMatch = teams[0];
|
||||
const teamId = teamMatch.id?.trim();
|
||||
const graphTeamId = teamMatch.id?.trim();
|
||||
const teamName = teamMatch.displayName?.trim() || team;
|
||||
if (!teamId) {
|
||||
if (!graphTeamId) {
|
||||
return { input, resolved: false, note: "team id missing" };
|
||||
}
|
||||
// Bot Framework sends the General channel's conversation ID as
|
||||
// channelData.team.id at runtime, NOT the Graph API group GUID.
|
||||
// Fetch channels upfront so we can resolve the correct key format for
|
||||
// runtime matching and reuse the list for channel lookups.
|
||||
let teamChannels: Awaited<ReturnType<typeof listChannelsForTeam>> = [];
|
||||
try {
|
||||
teamChannels = await listChannelsForTeam(token, graphTeamId);
|
||||
} catch {
|
||||
// API failure (rate limit, network error) — fall back to Graph GUID as team key
|
||||
}
|
||||
const generalChannel = teamChannels.find((ch) => ch.displayName?.toLowerCase() === "general");
|
||||
// Use the General channel's conversation ID as the team key — this
|
||||
// matches what Bot Framework sends at runtime. Fall back to the Graph
|
||||
// GUID if the General channel isn't found (renamed or deleted).
|
||||
const teamId = generalChannel?.id?.trim() || graphTeamId;
|
||||
if (!channel) {
|
||||
return {
|
||||
input,
|
||||
@@ -134,11 +149,11 @@ export async function resolveMSTeamsChannelAllowlist(params: {
|
||||
note: teams.length > 1 ? "multiple teams; chose first" : undefined,
|
||||
};
|
||||
}
|
||||
const channels = await listChannelsForTeam(token, teamId);
|
||||
// Reuse teamChannels — already fetched above
|
||||
const channelMatch =
|
||||
channels.find((item) => item.id === channel) ??
|
||||
channels.find((item) => item.displayName?.toLowerCase() === channel.toLowerCase()) ??
|
||||
channels.find((item) =>
|
||||
teamChannels.find((item) => item.id === channel) ??
|
||||
teamChannels.find((item) => item.displayName?.toLowerCase() === channel.toLowerCase()) ??
|
||||
teamChannels.find((item) =>
|
||||
item.displayName?.toLowerCase().includes(channel.toLowerCase() ?? ""),
|
||||
);
|
||||
if (!channelMatch?.id) {
|
||||
@@ -151,7 +166,7 @@ export async function resolveMSTeamsChannelAllowlist(params: {
|
||||
teamName,
|
||||
channelId: channelMatch.id,
|
||||
channelName: channelMatch.displayName ?? channel,
|
||||
note: channels.length > 1 ? "multiple channels; chose first" : undefined,
|
||||
note: teamChannels.length > 1 ? "multiple channels; chose first" : undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -57,18 +57,38 @@ function installGatewayRuntime(params?: { probeOk?: boolean; botUsername?: strin
|
||||
const probeTelegram = vi.fn(async () =>
|
||||
params?.probeOk ? { ok: true, bot: { username: params.botUsername ?? "bot" } } : { ok: false },
|
||||
);
|
||||
const collectUnmentionedGroupIds = vi.fn(() => ({
|
||||
groupIds: [] as string[],
|
||||
unresolvedGroups: 0,
|
||||
hasWildcardUnmentionedGroups: false,
|
||||
}));
|
||||
const auditGroupMembership = vi.fn(async () => ({
|
||||
ok: true,
|
||||
checkedGroups: 0,
|
||||
unresolvedGroups: 0,
|
||||
hasWildcardUnmentionedGroups: false,
|
||||
groups: [],
|
||||
elapsedMs: 0,
|
||||
}));
|
||||
setTelegramRuntime({
|
||||
channel: {
|
||||
telegram: {
|
||||
monitorTelegramProvider,
|
||||
probeTelegram,
|
||||
collectUnmentionedGroupIds,
|
||||
auditGroupMembership,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
return { monitorTelegramProvider, probeTelegram };
|
||||
return {
|
||||
monitorTelegramProvider,
|
||||
probeTelegram,
|
||||
collectUnmentionedGroupIds,
|
||||
auditGroupMembership,
|
||||
};
|
||||
}
|
||||
|
||||
describe("telegramPlugin duplicate token guard", () => {
|
||||
@@ -149,6 +169,85 @@ describe("telegramPlugin duplicate token guard", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes account proxy and network settings into Telegram probes", async () => {
|
||||
const { probeTelegram } = installGatewayRuntime({
|
||||
probeOk: true,
|
||||
botUsername: "opsbot",
|
||||
});
|
||||
|
||||
const cfg = createCfg();
|
||||
cfg.channels!.telegram!.accounts!.ops = {
|
||||
...cfg.channels!.telegram!.accounts!.ops,
|
||||
proxy: "http://127.0.0.1:8888",
|
||||
network: {
|
||||
autoSelectFamily: false,
|
||||
dnsResultOrder: "ipv4first",
|
||||
},
|
||||
};
|
||||
const account = telegramPlugin.config.resolveAccount(cfg, "ops");
|
||||
|
||||
await telegramPlugin.status!.probeAccount!({
|
||||
account,
|
||||
timeoutMs: 5000,
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(probeTelegram).toHaveBeenCalledWith("token-ops", 5000, {
|
||||
accountId: "ops",
|
||||
proxyUrl: "http://127.0.0.1:8888",
|
||||
network: {
|
||||
autoSelectFamily: false,
|
||||
dnsResultOrder: "ipv4first",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("passes account proxy and network settings into Telegram membership audits", async () => {
|
||||
const { collectUnmentionedGroupIds, auditGroupMembership } = installGatewayRuntime({
|
||||
probeOk: true,
|
||||
botUsername: "opsbot",
|
||||
});
|
||||
|
||||
collectUnmentionedGroupIds.mockReturnValue({
|
||||
groupIds: ["-100123"],
|
||||
unresolvedGroups: 0,
|
||||
hasWildcardUnmentionedGroups: false,
|
||||
});
|
||||
|
||||
const cfg = createCfg();
|
||||
cfg.channels!.telegram!.accounts!.ops = {
|
||||
...cfg.channels!.telegram!.accounts!.ops,
|
||||
proxy: "http://127.0.0.1:8888",
|
||||
network: {
|
||||
autoSelectFamily: false,
|
||||
dnsResultOrder: "ipv4first",
|
||||
},
|
||||
groups: {
|
||||
"-100123": { requireMention: false },
|
||||
},
|
||||
};
|
||||
const account = telegramPlugin.config.resolveAccount(cfg, "ops");
|
||||
|
||||
await telegramPlugin.status!.auditAccount!({
|
||||
account,
|
||||
timeoutMs: 5000,
|
||||
probe: { ok: true, bot: { id: 123 }, elapsedMs: 1 },
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(auditGroupMembership).toHaveBeenCalledWith({
|
||||
token: "token-ops",
|
||||
botId: 123,
|
||||
groupIds: ["-100123"],
|
||||
proxyUrl: "http://127.0.0.1:8888",
|
||||
network: {
|
||||
autoSelectFamily: false,
|
||||
dnsResultOrder: "ipv4first",
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards mediaLocalRoots to sendMessageTelegram for outbound media sends", async () => {
|
||||
const sendMessageTelegram = vi.fn(async () => ({ messageId: "tg-1" }));
|
||||
setTelegramRuntime({
|
||||
@@ -179,6 +278,41 @@ describe("telegramPlugin duplicate token guard", () => {
|
||||
expect(result).toMatchObject({ channel: "telegram", messageId: "tg-1" });
|
||||
});
|
||||
|
||||
it("preserves buttons for outbound text payload sends", async () => {
|
||||
const sendMessageTelegram = vi.fn(async () => ({ messageId: "tg-2" }));
|
||||
setTelegramRuntime({
|
||||
channel: {
|
||||
telegram: {
|
||||
sendMessageTelegram,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
|
||||
const result = await telegramPlugin.outbound!.sendPayload!({
|
||||
cfg: createCfg(),
|
||||
to: "12345",
|
||||
text: "",
|
||||
payload: {
|
||||
text: "Approval required",
|
||||
channelData: {
|
||||
telegram: {
|
||||
buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]],
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "ops",
|
||||
});
|
||||
|
||||
expect(sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"12345",
|
||||
"Approval required",
|
||||
expect.objectContaining({
|
||||
buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]],
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({ channel: "telegram", messageId: "tg-2" });
|
||||
});
|
||||
|
||||
it("ignores accounts with missing tokens during duplicate-token checks", async () => {
|
||||
const cfg = createCfg();
|
||||
cfg.channels!.telegram!.accounts!.ops = {} as never;
|
||||
|
||||
@@ -91,6 +91,10 @@ const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
},
|
||||
};
|
||||
|
||||
type TelegramInlineButtons = ReadonlyArray<
|
||||
ReadonlyArray<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }>
|
||||
>;
|
||||
|
||||
const telegramConfigAccessors = createScopedAccountConfigAccessors({
|
||||
resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }),
|
||||
resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom,
|
||||
@@ -317,6 +321,62 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
pollMaxOptions: 10,
|
||||
sendPayload: async ({
|
||||
cfg,
|
||||
to,
|
||||
payload,
|
||||
mediaLocalRoots,
|
||||
accountId,
|
||||
deps,
|
||||
replyToId,
|
||||
threadId,
|
||||
silent,
|
||||
}) => {
|
||||
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
|
||||
const replyToMessageId = parseTelegramReplyToMessageId(replyToId);
|
||||
const messageThreadId = parseTelegramThreadId(threadId);
|
||||
const telegramData = payload.channelData?.telegram as
|
||||
| { buttons?: TelegramInlineButtons; quoteText?: string }
|
||||
| undefined;
|
||||
const quoteText =
|
||||
typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined;
|
||||
const text = payload.text ?? "";
|
||||
const mediaUrls = payload.mediaUrls?.length
|
||||
? payload.mediaUrls
|
||||
: payload.mediaUrl
|
||||
? [payload.mediaUrl]
|
||||
: [];
|
||||
const baseOpts = {
|
||||
verbose: false,
|
||||
cfg,
|
||||
mediaLocalRoots,
|
||||
messageThreadId,
|
||||
replyToMessageId,
|
||||
quoteText,
|
||||
accountId: accountId ?? undefined,
|
||||
silent: silent ?? undefined,
|
||||
};
|
||||
|
||||
if (mediaUrls.length === 0) {
|
||||
const result = await send(to, text, {
|
||||
...baseOpts,
|
||||
buttons: telegramData?.buttons,
|
||||
});
|
||||
return { channel: "telegram", ...result };
|
||||
}
|
||||
|
||||
let finalResult: Awaited<ReturnType<typeof send>> | undefined;
|
||||
for (let i = 0; i < mediaUrls.length; i += 1) {
|
||||
const mediaUrl = mediaUrls[i];
|
||||
const isFirst = i === 0;
|
||||
finalResult = await send(to, isFirst ? text : "", {
|
||||
...baseOpts,
|
||||
mediaUrl,
|
||||
...(isFirst ? { buttons: telegramData?.buttons } : {}),
|
||||
});
|
||||
}
|
||||
return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) };
|
||||
},
|
||||
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => {
|
||||
const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
|
||||
const replyToMessageId = parseTelegramReplyToMessageId(replyToId);
|
||||
@@ -378,11 +438,11 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
collectStatusIssues: collectTelegramStatusIssues,
|
||||
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
getTelegramRuntime().channel.telegram.probeTelegram(
|
||||
account.token,
|
||||
timeoutMs,
|
||||
account.config.proxy,
|
||||
),
|
||||
getTelegramRuntime().channel.telegram.probeTelegram(account.token, timeoutMs, {
|
||||
accountId: account.accountId,
|
||||
proxyUrl: account.config.proxy,
|
||||
network: account.config.network,
|
||||
}),
|
||||
auditAccount: async ({ account, timeoutMs, probe, cfg }) => {
|
||||
const groups =
|
||||
cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ??
|
||||
@@ -408,6 +468,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
botId,
|
||||
groupIds,
|
||||
proxyUrl: account.config.proxy,
|
||||
network: account.config.network,
|
||||
timeoutMs,
|
||||
});
|
||||
return { ...audit, unresolvedGroups, hasWildcardUnmentionedGroups };
|
||||
@@ -471,11 +532,11 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
const token = (account.token ?? "").trim();
|
||||
let telegramBotLabel = "";
|
||||
try {
|
||||
const probe = await getTelegramRuntime().channel.telegram.probeTelegram(
|
||||
token,
|
||||
2500,
|
||||
account.config.proxy,
|
||||
);
|
||||
const probe = await getTelegramRuntime().channel.telegram.probeTelegram(token, 2500, {
|
||||
accountId: account.accountId,
|
||||
proxyUrl: account.config.proxy,
|
||||
network: account.config.network,
|
||||
});
|
||||
const username = probe.ok ? probe.bot?.username?.trim() : null;
|
||||
if (username) {
|
||||
telegramBotLabel = ` (@${username})`;
|
||||
|
||||
@@ -253,6 +253,11 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
|
||||
state: {
|
||||
resolveStateDir: vi.fn(() => "/tmp/openclaw"),
|
||||
},
|
||||
modelAuth: {
|
||||
getApiKeyForModel: vi.fn() as unknown as PluginRuntime["modelAuth"]["getApiKeyForModel"],
|
||||
resolveApiKeyForProvider:
|
||||
vi.fn() as unknown as PluginRuntime["modelAuth"]["resolveApiKeyForProvider"],
|
||||
},
|
||||
subagent: {
|
||||
run: vi.fn(),
|
||||
waitForRun: vi.fn(),
|
||||
|
||||
582
pnpm-lock.yaml
generated
582
pnpm-lock.yaml
generated
@@ -254,8 +254,8 @@ importers:
|
||||
extensions/acpx:
|
||||
dependencies:
|
||||
acpx:
|
||||
specifier: 0.1.15
|
||||
version: 0.1.15(zod@4.3.6)
|
||||
specifier: 0.1.16
|
||||
version: 0.1.16(zod@4.3.6)
|
||||
|
||||
extensions/bluebubbles:
|
||||
dependencies:
|
||||
@@ -338,8 +338,8 @@ importers:
|
||||
specifier: ^10.6.1
|
||||
version: 10.6.1
|
||||
openclaw:
|
||||
specifier: '>=2026.3.2'
|
||||
version: 2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3))
|
||||
specifier: '>=2026.3.7'
|
||||
version: 2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3))
|
||||
|
||||
extensions/imessage: {}
|
||||
|
||||
@@ -399,8 +399,8 @@ importers:
|
||||
extensions/memory-core:
|
||||
dependencies:
|
||||
openclaw:
|
||||
specifier: '>=2026.3.2'
|
||||
version: 2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3))
|
||||
specifier: '>=2026.3.7'
|
||||
version: 2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3))
|
||||
|
||||
extensions/memory-lancedb:
|
||||
dependencies:
|
||||
@@ -576,11 +576,6 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@agentclientprotocol/sdk@0.14.1':
|
||||
resolution: {integrity: sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w==}
|
||||
peerDependencies:
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
|
||||
'@agentclientprotocol/sdk@0.15.0':
|
||||
resolution: {integrity: sha512-TH4utu23Ix8ec34srBHmDD4p3HI0cYleS1jN9lghRczPfhFlMBNrQgZWeBBe12DWy27L11eIrtciY2MXFSEiDg==}
|
||||
peerDependencies:
|
||||
@@ -618,18 +613,10 @@ packages:
|
||||
'@aws-crypto/util@5.2.0':
|
||||
resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==}
|
||||
|
||||
'@aws-sdk/client-bedrock-runtime@3.1000.0':
|
||||
resolution: {integrity: sha512-GA96wgTFB4Z5vhysm+hErbgiEWZ9JqAl09BxARajL7Oanpf0KvdIjxuLp2rD/XqEIks9yG/5Rh9XIAoCUUTZXw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-bedrock-runtime@3.1004.0':
|
||||
resolution: {integrity: sha512-t8cl+bPLlHZQD2Sw1a4hSLUybqJZU71+m8znkyeU8CHntFqEp2mMbuLKdHKaAYQ1fAApXMsvzenCAkDzNeeJlw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-bedrock@3.1000.0':
|
||||
resolution: {integrity: sha512-wGU8uJXrPW/hZuHdPNVe1kAFIBiKcslBcoDBN0eYBzS13um8p5jJiQJ9WsD1nSpKCmyx7qZXc6xjcbIQPyOrrA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-bedrock@3.1004.0':
|
||||
resolution: {integrity: sha512-JbfZSV85IL+43S7rPBmeMbvoOYXs1wmrfbEpHkDBjkvbukRQWtoetiPAXNSKDfFq1qVsoq8sWPdoerDQwlUO8w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -718,18 +705,10 @@ packages:
|
||||
resolution: {integrity: sha512-g2Z9s6Y4iNh0wICaEqutgYgt/Pmhv5Ev9G3eKGFe2w9VuZDhc76vYdop6I5OocmpHV79d4TuLG+JWg5rQIVDVA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/eventstream-handler-node@3.972.9':
|
||||
resolution: {integrity: sha512-mKPiiVssgFDWkAXdEDh8+wpr2pFSX/fBn2onXXnrfIAYbdZhYb4WilKbZ3SJMUnQi+Y48jZMam5J0RrgARluaA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-bucket-endpoint@3.972.6':
|
||||
resolution: {integrity: sha512-3H2bhvb7Cb/S6WFsBy/Dy9q2aegC9JmGH1inO8Lb2sWirSqpLJlZmvQHPE29h2tIxzv6el/14X/tLCQ8BQU6ZQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-eventstream@3.972.6':
|
||||
resolution: {integrity: sha512-mB2+3G/oxRC+y9WRk0KCdradE2rSfxxJpcOSmAm+vDh3ex3WQHVLZ1catNIe1j5NQ+3FLBsNMRPVGkZ43PRpjw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-eventstream@3.972.7':
|
||||
resolution: {integrity: sha512-VWndapHYCfwLgPpCb/xwlMKG4imhFzKJzZcKOEioGn7OHY+6gdr0K7oqy1HZgbLa3ACznZ9fku+DzmAi8fUC0g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -786,10 +765,6 @@ packages:
|
||||
resolution: {integrity: sha512-Km90fcXt3W/iqujHzuM6IaDkYCj73gsYufcuWXApWdzoTy6KGk8fnchAjePMARU0xegIR3K4N3yIo1vy7OVe8A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-websocket@3.972.10':
|
||||
resolution: {integrity: sha512-uNqRpbL6djE+XXO4cQ+P8ra37cxNNBP+2IfkVOXu1xFdGMfW+uOTxBQuDPpP43i40PBRBXK5un79l/oYpbzYkA==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-websocket@3.972.12':
|
||||
resolution: {integrity: sha512-iyPP6FVDKe/5wy5ojC0akpDFG1vX3FeCUU47JuwN8xfvT66xlEI8qUJZPtN55TJVFzzWZJpWL78eqUE31md08Q==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
@@ -818,10 +793,6 @@ packages:
|
||||
resolution: {integrity: sha512-gQYI/Buwp0CAGQxY7mR5VzkP56rkWq2Y1ROkFuXh5XY94DsSjJw62B3I0N0lysQmtwiL2ht2KHI9NylM/RP4FA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/token-providers@3.1000.0':
|
||||
resolution: {integrity: sha512-eOI+8WPtWpLdlYBGs8OCK3k5uIMUHVsNG3AFO4kaRaZcKReJ/2OO6+2O2Dd/3vTzM56kRjSKe7mBOCwa4PdYqg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/token-providers@3.1004.0':
|
||||
resolution: {integrity: sha512-j9BwZZId9sFp+4GPhf6KrwO8Tben2sXibZA8D1vv2I1zBdvkUHcBA2g4pkqIpTRalMTLC0NPkBPX0gERxfy/iA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -980,15 +951,9 @@ packages:
|
||||
'@cacheable/utils@2.3.4':
|
||||
resolution: {integrity: sha512-knwKUJEYgIfwShABS1BX6JyJJTglAFcEU7EXqzTdiGCXur4voqkiJkdgZIQtWNFhynzDWERcTYv/sETMu3uJWA==}
|
||||
|
||||
'@clack/core@1.0.1':
|
||||
resolution: {integrity: sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g==}
|
||||
|
||||
'@clack/core@1.1.0':
|
||||
resolution: {integrity: sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==}
|
||||
|
||||
'@clack/prompts@1.0.1':
|
||||
resolution: {integrity: sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q==}
|
||||
|
||||
'@clack/prompts@1.1.0':
|
||||
resolution: {integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==}
|
||||
|
||||
@@ -1222,15 +1187,6 @@ packages:
|
||||
'@eshaz/web-worker@1.2.2':
|
||||
resolution: {integrity: sha512-WxXiHFmD9u/owrzempiDlBB1ZYqiLnm9s6aPc8AlFQalq2tKmqdmMr9GXOupDgzXtqnBipj8Un0gkIm7Sjf8mw==}
|
||||
|
||||
'@google/genai@1.43.0':
|
||||
resolution: {integrity: sha512-hklCsJNdMlDM1IwcCVcGQFBg2izY0+t5BIGbRsxi2UnKi6AGKL7pqJqmBDNRbw0bYCs4y3NA7TB+fkKfP/Nrdw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
'@modelcontextprotocol/sdk': ^1.25.2
|
||||
peerDependenciesMeta:
|
||||
'@modelcontextprotocol/sdk':
|
||||
optional: true
|
||||
|
||||
'@google/genai@1.44.0':
|
||||
resolution: {integrity: sha512-kRt9ZtuXmz+tLlcNntN/VV4LRdpl6ZOu5B1KbfNgfR65db15O6sUQcwnwLka8sT/V6qysD93fWrgJHF2L7dA9A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -1644,38 +1600,20 @@ packages:
|
||||
resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==}
|
||||
hasBin: true
|
||||
|
||||
'@mariozechner/pi-agent-core@0.55.3':
|
||||
resolution: {integrity: sha512-rqbfpQ9BrP6BDiW+Ps3A8Z/p9+Md/pAfc/ECq8JP6cwnZL/jQgU355KWZKtF8zM9az1p0Q9hIWi9cQygVo6Auw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@mariozechner/pi-agent-core@0.57.1':
|
||||
resolution: {integrity: sha512-WXsBbkNWOObFGHkhixaT8GXJpHDd3+fn8QntYF+4R8Sa9WB90ENXWidO6b7vcKX+JX0jjO5dIsQxmzosARJKlg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@mariozechner/pi-ai@0.55.3':
|
||||
resolution: {integrity: sha512-f9jWoDzJR9Wy/H8JPMbjoM4WvVUeFZ65QdYA9UHIfoOopDfwWE8F8JHQOj5mmmILMacXuzsqA3J7MYqNWZRvvQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@mariozechner/pi-ai@0.57.1':
|
||||
resolution: {integrity: sha512-Bd/J4a3YpdzJVyHLih0vDSdB0QPL4ti0XsAwtHOK/8eVhB0fHM1CpcgIrcBFJ23TMcKXMi0qamz18ERfp8tmgg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@mariozechner/pi-coding-agent@0.55.3':
|
||||
resolution: {integrity: sha512-5SFbB7/BIp/Crjre7UNjUeNfpoU1KSW/i6LXa+ikJTBqI5LukWq2avE5l0v0M8Pg/dt1go2XCLrNFlQJiQDSPQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@mariozechner/pi-coding-agent@0.57.1':
|
||||
resolution: {integrity: sha512-u5MQEduj68rwVIsRsqrWkJYiJCyPph/a6bMoJAQKo1sb+Pc17Y/ojwa+wGssnUMjEB38AQKofWTVe8NFEpSWNw==}
|
||||
engines: {node: '>=20.6.0'}
|
||||
hasBin: true
|
||||
|
||||
'@mariozechner/pi-tui@0.55.3':
|
||||
resolution: {integrity: sha512-Gh4wkYgiSPCJJaB/4wEWSL7Ga8bxSq1Crp1RPRT4vKybE/DG0W/MQr5VJDvktarxtJrD16ixScwE4dzdox/PIA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@mariozechner/pi-tui@0.57.1':
|
||||
resolution: {integrity: sha512-cjoRghLbeAHV0tTJeHgZXaryUi5zzBZofeZ7uJun1gztnckLLRjoVeaPTujNlc5BIfyKvFqhh1QWCZng/MXlpg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -1692,9 +1630,6 @@ packages:
|
||||
resolution: {integrity: sha512-570oJr93l1RcCNNaMVpOm+PgQkRgno/F65nH1aCWLIKLnw0o7iPoj+8Z5b7mnLMidg9lldVSCcf0dBxqTGE1/w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@mistralai/mistralai@1.10.0':
|
||||
resolution: {integrity: sha512-tdIgWs4Le8vpvPiUEWne6tK0qbVc+jMenujnvTqOjogrJUsCSQhus0tHTU1avDDh5//Rq2dFgP9mWRAdIEoBqg==}
|
||||
|
||||
'@mistralai/mistralai@1.14.1':
|
||||
resolution: {integrity: sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ==}
|
||||
|
||||
@@ -3198,93 +3133,6 @@ packages:
|
||||
resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@snazzah/davey-android-arm-eabi@0.1.9':
|
||||
resolution: {integrity: sha512-Dq0WyeVGBw+uQbisV/6PeCQV2ndJozfhZqiNIfQxu6ehIdXB7iHILv+oY+AQN2n+qxiFmLh/MOX9RF+pIWdPbA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@snazzah/davey-android-arm64@0.1.9':
|
||||
resolution: {integrity: sha512-OE16OZjv7F/JrD7Mzw5eL2gY2vXRPC8S7ZrmkcMyz/sHHJsGHlT+L7X5s56Bec1YDTVmzAsH4UBuvVBoXuIWEQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@snazzah/davey-darwin-arm64@0.1.9':
|
||||
resolution: {integrity: sha512-z7oORvAPExikFkH6tvHhbUdZd77MYZp9VqbCpKEiI+sisWFVXgHde7F7iH3G4Bz6gUYJfgvKhWXiDRc+0SC4dg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@snazzah/davey-darwin-x64@0.1.9':
|
||||
resolution: {integrity: sha512-f1LzGyRGlM414KpXml3OgWVSd7CgylcdYaFj/zDBb8bvWjxyvsI9iMeuPfe/cduloxRj8dELde/yCDZtFR6PdQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@snazzah/davey-freebsd-x64@0.1.9':
|
||||
resolution: {integrity: sha512-k6p3JY2b8rD6j0V9Ql7kBUMR4eJdcpriNwiHltLzmtGuz/nK5RGQdkEP68gTLc+Uj3xs5Cy0jRKmv2xJQBR4sA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@snazzah/davey-linux-arm-gnueabihf@0.1.9':
|
||||
resolution: {integrity: sha512-xDaAFUC/1+n/YayNwKsqKOBMuW0KI6F0SjgWU+krYTQTVmAKNjOM80IjemrVoqTpBOxBsT80zEtct2wj11CE3Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@snazzah/davey-linux-arm64-gnu@0.1.9':
|
||||
resolution: {integrity: sha512-t1VxFBzWExPNpsNY/9oStdAAuHqFvwZvIO2YPYyVNstxfi2KmAbHMweHUW7xb2ppXuhVQZ4VGmmeXiXcXqhPBw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@snazzah/davey-linux-arm64-musl@0.1.9':
|
||||
resolution: {integrity: sha512-Xvlr+nBPzuFV4PXHufddlt08JsEyu0p8mX2DpqdPxdpysYIH4I8V86yJiS4tk04a6pLBDd8IxTbBwvXJKqd/LQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@snazzah/davey-linux-x64-gnu@0.1.9':
|
||||
resolution: {integrity: sha512-6Uunc/NxiEkg1reroAKZAGfOtjl1CGa7hfTTVClb2f+DiA8ZRQWBh+3lgkq/0IeL262B4F14X8QRv5Bsv128qw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@snazzah/davey-linux-x64-musl@0.1.9':
|
||||
resolution: {integrity: sha512-fFQ/n3aWt1lXhxSdy+Ge3gi5bR3VETMVsWhH0gwBALUKrbo3ZzgSktm4lNrXE9i0ncMz/CDpZ5i0wt/N3XphEQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@snazzah/davey-wasm32-wasi@0.1.9':
|
||||
resolution: {integrity: sha512-xWvzej8YCVlUvzlpmqJMIf0XmLlHqulKZ2e7WNe2TxQmsK+o0zTZqiQYs2MwaEbrNXBhYlHDkdpuwoXkJdscNQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@snazzah/davey-win32-arm64-msvc@0.1.9':
|
||||
resolution: {integrity: sha512-sTqry/DfltX2OdW1CTLKa3dFYN5FloAEb2yhGsY1i5+Bms6OhwByXfALvyMHYVo61Th2+sD+9BJpQffHFKDA3w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@snazzah/davey-win32-ia32-msvc@0.1.9':
|
||||
resolution: {integrity: sha512-twD3LwlkGnSwphsCtpGb5ztpBIWEvGdc0iujoVkdzZ6nJiq5p8iaLjJMO4hBm9h3s28fc+1Qd7AMVnagiOasnA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@snazzah/davey-win32-x64-msvc@0.1.9':
|
||||
resolution: {integrity: sha512-eMnXbv4GoTngWYY538i/qHz2BS+RgSXFsvKltPzKqnqzPzhQZIY7TemEJn3D5yWGfW4qHve9u23rz93FQqnQMA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@snazzah/davey@0.1.9':
|
||||
resolution: {integrity: sha512-vNZk5y+IsxjwzTAXikvzz5pqMLb35YytC64nVF2MAFVhjpXu9ITOKUriZ0JG/llwzCAi56jb5x0cXDRIyE2A2A==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
@@ -3670,9 +3518,9 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
acpx@0.1.15:
|
||||
resolution: {integrity: sha512-1r+tmPT9Oe2Ulv5b4r7O2hCCq5CHVru/H2tcPeTpZek9jR1zBQoBfZ/RcK+9sC9/mnDvWYO5R7Iae64v2LMO+A==}
|
||||
engines: {node: '>=18'}
|
||||
acpx@0.1.16:
|
||||
resolution: {integrity: sha512-CxHkUIP9dPSjh+RyoZkQg0AXjSiSus/dF4xKEeG9c+7JboZp5bZuWie/n4V7sBeKTMheMoEYGrMUslrdUadrqg==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
agent-base@6.0.2:
|
||||
@@ -4059,10 +3907,6 @@ packages:
|
||||
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
commander@13.1.0:
|
||||
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
commander@14.0.3:
|
||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -4210,9 +4054,6 @@ packages:
|
||||
discord-api-types@0.38.37:
|
||||
resolution: {integrity: sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==}
|
||||
|
||||
discord-api-types@0.38.40:
|
||||
resolution: {integrity: sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ==}
|
||||
|
||||
discord-api-types@0.38.41:
|
||||
resolution: {integrity: sha512-yMECyR8j9c2fVTvCQ+Qc24pweYFIZk/XoxDOmt1UvPeSw5tK6gXBd/2hhP+FEAe9Y6ny8pRMaf618XDK4U53OQ==}
|
||||
|
||||
@@ -4614,10 +4455,6 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
grammy@1.41.0:
|
||||
resolution: {integrity: sha512-CAAu74SLT+/QCg40FBhUuYJalVsxxCN3D0c31TzhFBsWWTdXrMXYjGsKngBdfvN6hQ/VzHczluj/ugZVetFNCQ==}
|
||||
engines: {node: ^12.20.0 || >=14.13.1}
|
||||
|
||||
grammy@1.41.1:
|
||||
resolution: {integrity: sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ==}
|
||||
engines: {node: ^12.20.0 || >=14.13.1}
|
||||
@@ -5466,18 +5303,6 @@ packages:
|
||||
oniguruma-to-es@4.3.4:
|
||||
resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==}
|
||||
|
||||
openai@6.10.0:
|
||||
resolution: {integrity: sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
ws: ^8.18.0
|
||||
zod: ^3.25 || ^4.0
|
||||
peerDependenciesMeta:
|
||||
ws:
|
||||
optional: true
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
openai@6.26.0:
|
||||
resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==}
|
||||
hasBin: true
|
||||
@@ -5502,8 +5327,8 @@ packages:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
openclaw@2026.3.2:
|
||||
resolution: {integrity: sha512-Gkqx24m7PF1DUXPI968DuC9n52lTZ5hI3X5PIi0HosC7J7d6RLkgVppj1mxvgiQAWMp41E41elvoi/h4KBjFcQ==}
|
||||
openclaw@2026.3.8:
|
||||
resolution: {integrity: sha512-e5Rk2Aj55sD/5LyX94mdYCQj7zpHXo0xIZsl+k140+nRopePfPAxC7nsu0V/NyypPRtaotP1riFfzK7IhaYkuQ==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -6746,9 +6571,6 @@ packages:
|
||||
zod@3.25.75:
|
||||
resolution: {integrity: sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg==}
|
||||
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zod@4.3.6:
|
||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||
|
||||
@@ -6757,10 +6579,6 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@agentclientprotocol/sdk@0.14.1(zod@4.3.6)':
|
||||
dependencies:
|
||||
zod: 4.3.6
|
||||
|
||||
'@agentclientprotocol/sdk@0.15.0(zod@4.3.6)':
|
||||
dependencies:
|
||||
zod: 4.3.6
|
||||
@@ -6818,58 +6636,6 @@ snapshots:
|
||||
'@smithy/util-utf8': 2.3.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-bedrock-runtime@3.1000.0':
|
||||
dependencies:
|
||||
'@aws-crypto/sha256-browser': 5.2.0
|
||||
'@aws-crypto/sha256-js': 5.2.0
|
||||
'@aws-sdk/core': 3.973.15
|
||||
'@aws-sdk/credential-provider-node': 3.972.14
|
||||
'@aws-sdk/eventstream-handler-node': 3.972.9
|
||||
'@aws-sdk/middleware-eventstream': 3.972.6
|
||||
'@aws-sdk/middleware-host-header': 3.972.6
|
||||
'@aws-sdk/middleware-logger': 3.972.6
|
||||
'@aws-sdk/middleware-recursion-detection': 3.972.6
|
||||
'@aws-sdk/middleware-user-agent': 3.972.15
|
||||
'@aws-sdk/middleware-websocket': 3.972.10
|
||||
'@aws-sdk/region-config-resolver': 3.972.6
|
||||
'@aws-sdk/token-providers': 3.1000.0
|
||||
'@aws-sdk/types': 3.973.4
|
||||
'@aws-sdk/util-endpoints': 3.996.3
|
||||
'@aws-sdk/util-user-agent-browser': 3.972.6
|
||||
'@aws-sdk/util-user-agent-node': 3.973.0
|
||||
'@smithy/config-resolver': 4.4.9
|
||||
'@smithy/core': 3.23.6
|
||||
'@smithy/eventstream-serde-browser': 4.2.10
|
||||
'@smithy/eventstream-serde-config-resolver': 4.3.10
|
||||
'@smithy/eventstream-serde-node': 4.2.10
|
||||
'@smithy/fetch-http-handler': 5.3.11
|
||||
'@smithy/hash-node': 4.2.10
|
||||
'@smithy/invalid-dependency': 4.2.10
|
||||
'@smithy/middleware-content-length': 4.2.10
|
||||
'@smithy/middleware-endpoint': 4.4.20
|
||||
'@smithy/middleware-retry': 4.4.37
|
||||
'@smithy/middleware-serde': 4.2.11
|
||||
'@smithy/middleware-stack': 4.2.10
|
||||
'@smithy/node-config-provider': 4.3.10
|
||||
'@smithy/node-http-handler': 4.4.12
|
||||
'@smithy/protocol-http': 5.3.10
|
||||
'@smithy/smithy-client': 4.12.0
|
||||
'@smithy/types': 4.13.0
|
||||
'@smithy/url-parser': 4.2.10
|
||||
'@smithy/util-base64': 4.3.1
|
||||
'@smithy/util-body-length-browser': 4.2.1
|
||||
'@smithy/util-body-length-node': 4.2.2
|
||||
'@smithy/util-defaults-mode-browser': 4.3.36
|
||||
'@smithy/util-defaults-mode-node': 4.2.39
|
||||
'@smithy/util-endpoints': 3.3.1
|
||||
'@smithy/util-middleware': 4.2.10
|
||||
'@smithy/util-retry': 4.2.10
|
||||
'@smithy/util-stream': 4.5.15
|
||||
'@smithy/util-utf8': 4.2.1
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/client-bedrock-runtime@3.1004.0':
|
||||
dependencies:
|
||||
'@aws-crypto/sha256-browser': 5.2.0
|
||||
@@ -6922,51 +6688,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/client-bedrock@3.1000.0':
|
||||
dependencies:
|
||||
'@aws-crypto/sha256-browser': 5.2.0
|
||||
'@aws-crypto/sha256-js': 5.2.0
|
||||
'@aws-sdk/core': 3.973.15
|
||||
'@aws-sdk/credential-provider-node': 3.972.14
|
||||
'@aws-sdk/middleware-host-header': 3.972.6
|
||||
'@aws-sdk/middleware-logger': 3.972.6
|
||||
'@aws-sdk/middleware-recursion-detection': 3.972.6
|
||||
'@aws-sdk/middleware-user-agent': 3.972.15
|
||||
'@aws-sdk/region-config-resolver': 3.972.6
|
||||
'@aws-sdk/token-providers': 3.1000.0
|
||||
'@aws-sdk/types': 3.973.4
|
||||
'@aws-sdk/util-endpoints': 3.996.3
|
||||
'@aws-sdk/util-user-agent-browser': 3.972.6
|
||||
'@aws-sdk/util-user-agent-node': 3.973.0
|
||||
'@smithy/config-resolver': 4.4.9
|
||||
'@smithy/core': 3.23.6
|
||||
'@smithy/fetch-http-handler': 5.3.11
|
||||
'@smithy/hash-node': 4.2.10
|
||||
'@smithy/invalid-dependency': 4.2.10
|
||||
'@smithy/middleware-content-length': 4.2.10
|
||||
'@smithy/middleware-endpoint': 4.4.20
|
||||
'@smithy/middleware-retry': 4.4.37
|
||||
'@smithy/middleware-serde': 4.2.11
|
||||
'@smithy/middleware-stack': 4.2.10
|
||||
'@smithy/node-config-provider': 4.3.10
|
||||
'@smithy/node-http-handler': 4.4.12
|
||||
'@smithy/protocol-http': 5.3.10
|
||||
'@smithy/smithy-client': 4.12.0
|
||||
'@smithy/types': 4.13.0
|
||||
'@smithy/url-parser': 4.2.10
|
||||
'@smithy/util-base64': 4.3.1
|
||||
'@smithy/util-body-length-browser': 4.2.1
|
||||
'@smithy/util-body-length-node': 4.2.2
|
||||
'@smithy/util-defaults-mode-browser': 4.3.36
|
||||
'@smithy/util-defaults-mode-node': 4.2.39
|
||||
'@smithy/util-endpoints': 3.3.1
|
||||
'@smithy/util-middleware': 4.2.10
|
||||
'@smithy/util-retry': 4.2.10
|
||||
'@smithy/util-utf8': 4.2.1
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/client-bedrock@3.1004.0':
|
||||
dependencies:
|
||||
'@aws-crypto/sha256-browser': 5.2.0
|
||||
@@ -7324,13 +7045,6 @@ snapshots:
|
||||
'@smithy/types': 4.13.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/eventstream-handler-node@3.972.9':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.4
|
||||
'@smithy/eventstream-codec': 4.2.10
|
||||
'@smithy/types': 4.13.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-bucket-endpoint@3.972.6':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.4
|
||||
@@ -7341,13 +7055,6 @@ snapshots:
|
||||
'@smithy/util-config-provider': 4.2.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-eventstream@3.972.6':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.4
|
||||
'@smithy/protocol-http': 5.3.10
|
||||
'@smithy/types': 4.13.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-eventstream@3.972.7':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.5
|
||||
@@ -7471,21 +7178,6 @@ snapshots:
|
||||
'@smithy/util-retry': 4.2.11
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-websocket@3.972.10':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.4
|
||||
'@aws-sdk/util-format-url': 3.972.6
|
||||
'@smithy/eventstream-codec': 4.2.10
|
||||
'@smithy/eventstream-serde-browser': 4.2.10
|
||||
'@smithy/fetch-http-handler': 5.3.11
|
||||
'@smithy/protocol-http': 5.3.10
|
||||
'@smithy/signature-v4': 5.3.10
|
||||
'@smithy/types': 4.13.0
|
||||
'@smithy/util-base64': 4.3.1
|
||||
'@smithy/util-hex-encoding': 4.2.1
|
||||
'@smithy/util-utf8': 4.2.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-websocket@3.972.12':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.5
|
||||
@@ -7623,18 +7315,6 @@ snapshots:
|
||||
'@smithy/types': 4.13.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1000.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.973.15
|
||||
'@aws-sdk/nested-clients': 3.996.3
|
||||
'@aws-sdk/types': 3.973.4
|
||||
'@smithy/property-provider': 4.2.10
|
||||
'@smithy/shared-ini-file-loader': 4.4.5
|
||||
'@smithy/types': 4.13.0
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/token-providers@3.1004.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.973.18
|
||||
@@ -7858,21 +7538,10 @@ snapshots:
|
||||
hashery: 1.5.0
|
||||
keyv: 5.6.0
|
||||
|
||||
'@clack/core@1.0.1':
|
||||
dependencies:
|
||||
picocolors: 1.1.1
|
||||
sisteransi: 1.0.5
|
||||
|
||||
'@clack/core@1.1.0':
|
||||
dependencies:
|
||||
sisteransi: 1.0.5
|
||||
|
||||
'@clack/prompts@1.0.1':
|
||||
dependencies:
|
||||
'@clack/core': 1.0.1
|
||||
picocolors: 1.1.1
|
||||
sisteransi: 1.0.5
|
||||
|
||||
'@clack/prompts@1.1.0':
|
||||
dependencies:
|
||||
'@clack/core': 1.1.0
|
||||
@@ -8100,17 +7769,6 @@ snapshots:
|
||||
'@eshaz/web-worker@1.2.2':
|
||||
optional: true
|
||||
|
||||
'@google/genai@1.43.0':
|
||||
dependencies:
|
||||
google-auth-library: 10.6.1
|
||||
p-retry: 4.6.2
|
||||
protobufjs: 7.5.4
|
||||
ws: 8.19.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@google/genai@1.44.0':
|
||||
dependencies:
|
||||
google-auth-library: 10.6.1
|
||||
@@ -8122,21 +7780,11 @@ snapshots:
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@grammyjs/runner@2.0.3(grammy@1.41.0)':
|
||||
dependencies:
|
||||
abort-controller: 3.0.0
|
||||
grammy: 1.41.0
|
||||
|
||||
'@grammyjs/runner@2.0.3(grammy@1.41.1)':
|
||||
dependencies:
|
||||
abort-controller: 3.0.0
|
||||
grammy: 1.41.1
|
||||
|
||||
'@grammyjs/transformer-throttler@1.2.1(grammy@1.41.0)':
|
||||
dependencies:
|
||||
bottleneck: 2.19.5
|
||||
grammy: 1.41.0
|
||||
|
||||
'@grammyjs/transformer-throttler@1.2.1(grammy@1.41.1)':
|
||||
dependencies:
|
||||
bottleneck: 2.19.5
|
||||
@@ -8501,18 +8149,6 @@ snapshots:
|
||||
std-env: 3.10.0
|
||||
yoctocolors: 2.1.2
|
||||
|
||||
'@mariozechner/pi-agent-core@0.55.3(ws@8.19.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-ai': 0.55.3(ws@8.19.0)(zod@4.3.6)
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- aws-crt
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-agent-core@0.57.1(ws@8.19.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@4.3.6)
|
||||
@@ -8525,30 +8161,6 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-ai@0.55.3(ws@8.19.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
|
||||
'@aws-sdk/client-bedrock-runtime': 3.1000.0
|
||||
'@google/genai': 1.43.0
|
||||
'@mistralai/mistralai': 1.10.0
|
||||
'@sinclair/typebox': 0.34.48
|
||||
ajv: 8.18.0
|
||||
ajv-formats: 3.0.1(ajv@8.18.0)
|
||||
chalk: 5.6.2
|
||||
openai: 6.10.0(ws@8.19.0)(zod@4.3.6)
|
||||
partial-json: 0.1.7
|
||||
proxy-agent: 6.5.0
|
||||
undici: 7.22.0
|
||||
zod-to-json-schema: 3.25.1(zod@4.3.6)
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- aws-crt
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-ai@0.57.1(ws@8.19.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
|
||||
@@ -8573,37 +8185,6 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-coding-agent@0.55.3(ws@8.19.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@mariozechner/jiti': 2.6.5
|
||||
'@mariozechner/pi-agent-core': 0.55.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai': 0.55.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-tui': 0.55.3
|
||||
'@silvia-odwyer/photon-node': 0.3.4
|
||||
chalk: 5.6.2
|
||||
cli-highlight: 2.1.11
|
||||
diff: 8.0.3
|
||||
extract-zip: 2.0.1
|
||||
file-type: 21.3.0
|
||||
glob: 13.0.6
|
||||
hosted-git-info: 9.0.2
|
||||
ignore: 7.0.5
|
||||
marked: 15.0.12
|
||||
minimatch: 10.2.4
|
||||
proper-lockfile: 4.1.2
|
||||
strip-ansi: 7.2.0
|
||||
yaml: 2.8.2
|
||||
optionalDependencies:
|
||||
'@mariozechner/clipboard': 0.3.2
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- aws-crt
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-coding-agent@0.57.1(ws@8.19.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@mariozechner/jiti': 2.6.5
|
||||
@@ -8636,15 +8217,6 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-tui@0.55.3':
|
||||
dependencies:
|
||||
'@types/mime-types': 2.1.4
|
||||
chalk: 5.6.2
|
||||
get-east-asian-width: 1.5.0
|
||||
koffi: 2.15.1
|
||||
marked: 15.0.12
|
||||
mime-types: 3.0.2
|
||||
|
||||
'@mariozechner/pi-tui@0.57.1':
|
||||
dependencies:
|
||||
'@types/mime-types': 2.1.4
|
||||
@@ -8684,11 +8256,6 @@ snapshots:
|
||||
- debug
|
||||
- supports-color
|
||||
|
||||
'@mistralai/mistralai@1.10.0':
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
zod-to-json-schema: 3.25.1(zod@3.25.76)
|
||||
|
||||
'@mistralai/mistralai@1.14.1':
|
||||
dependencies:
|
||||
ws: 8.19.0
|
||||
@@ -10291,67 +9858,6 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@snazzah/davey-android-arm-eabi@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-android-arm64@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-darwin-arm64@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-darwin-x64@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-freebsd-x64@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-arm-gnueabihf@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-arm64-gnu@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-arm64-musl@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-x64-gnu@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-linux-x64-musl@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-wasm32-wasi@0.1.9':
|
||||
dependencies:
|
||||
'@napi-rs/wasm-runtime': 1.1.1
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-win32-arm64-msvc@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-win32-ia32-msvc@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey-win32-x64-msvc@0.1.9':
|
||||
optional: true
|
||||
|
||||
'@snazzah/davey@0.1.9':
|
||||
optionalDependencies:
|
||||
'@snazzah/davey-android-arm-eabi': 0.1.9
|
||||
'@snazzah/davey-android-arm64': 0.1.9
|
||||
'@snazzah/davey-darwin-arm64': 0.1.9
|
||||
'@snazzah/davey-darwin-x64': 0.1.9
|
||||
'@snazzah/davey-freebsd-x64': 0.1.9
|
||||
'@snazzah/davey-linux-arm-gnueabihf': 0.1.9
|
||||
'@snazzah/davey-linux-arm64-gnu': 0.1.9
|
||||
'@snazzah/davey-linux-arm64-musl': 0.1.9
|
||||
'@snazzah/davey-linux-x64-gnu': 0.1.9
|
||||
'@snazzah/davey-linux-x64-musl': 0.1.9
|
||||
'@snazzah/davey-wasm32-wasi': 0.1.9
|
||||
'@snazzah/davey-win32-arm64-msvc': 0.1.9
|
||||
'@snazzah/davey-win32-ia32-msvc': 0.1.9
|
||||
'@snazzah/davey-win32-x64-msvc': 0.1.9
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@swc/helpers@0.5.19':
|
||||
@@ -10860,10 +10366,10 @@ snapshots:
|
||||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
acpx@0.1.15(zod@4.3.6):
|
||||
acpx@0.1.16(zod@4.3.6):
|
||||
dependencies:
|
||||
'@agentclientprotocol/sdk': 0.14.1(zod@4.3.6)
|
||||
commander: 13.1.0
|
||||
'@agentclientprotocol/sdk': 0.15.0(zod@4.3.6)
|
||||
commander: 14.0.3
|
||||
skillflag: 0.1.4
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
@@ -11257,8 +10763,6 @@ snapshots:
|
||||
|
||||
commander@10.0.1: {}
|
||||
|
||||
commander@13.1.0: {}
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
commander@5.1.0: {}
|
||||
@@ -11364,8 +10868,6 @@ snapshots:
|
||||
|
||||
discord-api-types@0.38.37: {}
|
||||
|
||||
discord-api-types@0.38.40: {}
|
||||
|
||||
discord-api-types@0.38.41: {}
|
||||
|
||||
doctypes@1.1.0: {}
|
||||
@@ -11876,16 +11378,6 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
grammy@1.41.0:
|
||||
dependencies:
|
||||
'@grammyjs/types': 3.25.0
|
||||
abort-controller: 3.0.0
|
||||
debug: 4.4.3
|
||||
node-fetch: 2.7.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
grammy@1.41.1:
|
||||
dependencies:
|
||||
'@grammyjs/types': 3.25.0
|
||||
@@ -12287,7 +11779,8 @@ snapshots:
|
||||
|
||||
klona@2.0.6: {}
|
||||
|
||||
koffi@2.15.1: {}
|
||||
koffi@2.15.1:
|
||||
optional: true
|
||||
|
||||
leac@0.6.0: {}
|
||||
|
||||
@@ -12806,11 +12299,6 @@ snapshots:
|
||||
regex: 6.1.0
|
||||
regex-recursion: 6.0.2
|
||||
|
||||
openai@6.10.0(ws@8.19.0)(zod@4.3.6):
|
||||
optionalDependencies:
|
||||
ws: 8.19.0
|
||||
zod: 4.3.6
|
||||
|
||||
openai@6.26.0(ws@8.19.0)(zod@4.3.6):
|
||||
optionalDependencies:
|
||||
ws: 8.19.0
|
||||
@@ -12821,29 +12309,28 @@ snapshots:
|
||||
ws: 8.19.0
|
||||
zod: 4.3.6
|
||||
|
||||
openclaw@2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)):
|
||||
openclaw@2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@agentclientprotocol/sdk': 0.14.1(zod@4.3.6)
|
||||
'@aws-sdk/client-bedrock': 3.1000.0
|
||||
'@agentclientprotocol/sdk': 0.15.0(zod@4.3.6)
|
||||
'@aws-sdk/client-bedrock': 3.1004.0
|
||||
'@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.5)(opusscript@0.1.1)
|
||||
'@clack/prompts': 1.0.1
|
||||
'@clack/prompts': 1.1.0
|
||||
'@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1)
|
||||
'@grammyjs/runner': 2.0.3(grammy@1.41.0)
|
||||
'@grammyjs/transformer-throttler': 1.2.1(grammy@1.41.0)
|
||||
'@grammyjs/runner': 2.0.3(grammy@1.41.1)
|
||||
'@grammyjs/transformer-throttler': 1.2.1(grammy@1.41.1)
|
||||
'@homebridge/ciao': 1.3.5
|
||||
'@larksuiteoapi/node-sdk': 1.59.0
|
||||
'@line/bot-sdk': 10.6.0
|
||||
'@lydell/node-pty': 1.2.0-beta.3
|
||||
'@mariozechner/pi-agent-core': 0.55.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai': 0.55.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-coding-agent': 0.55.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-tui': 0.55.3
|
||||
'@mariozechner/pi-agent-core': 0.57.1(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-coding-agent': 0.57.1(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-tui': 0.57.1
|
||||
'@mozilla/readability': 0.6.0
|
||||
'@napi-rs/canvas': 0.1.95
|
||||
'@sinclair/typebox': 0.34.48
|
||||
'@slack/bolt': 4.6.0(@types/express@5.0.6)
|
||||
'@slack/web-api': 7.14.1
|
||||
'@snazzah/davey': 0.1.9
|
||||
'@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)
|
||||
ajv: 8.18.0
|
||||
chalk: 5.6.2
|
||||
@@ -12851,13 +12338,11 @@ snapshots:
|
||||
cli-highlight: 2.1.11
|
||||
commander: 14.0.3
|
||||
croner: 10.0.1
|
||||
discord-api-types: 0.38.40
|
||||
discord-api-types: 0.38.41
|
||||
dotenv: 17.3.1
|
||||
express: 5.2.1
|
||||
file-type: 21.3.0
|
||||
gaxios: 7.1.3
|
||||
google-auth-library: 10.6.1
|
||||
grammy: 1.41.0
|
||||
grammy: 1.41.1
|
||||
https-proxy-agent: 7.0.6
|
||||
ipaddr.js: 2.3.0
|
||||
jiti: 2.6.1
|
||||
@@ -12866,7 +12351,6 @@ snapshots:
|
||||
linkedom: 0.18.12
|
||||
long: 5.3.2
|
||||
markdown-it: 14.1.1
|
||||
node-domexception: '@nolyfill/domexception@1.0.28'
|
||||
node-edge-tts: 1.2.10
|
||||
node-llama-cpp: 3.16.2(typescript@5.9.3)
|
||||
opusscript: 0.1.1
|
||||
@@ -12876,16 +12360,14 @@ snapshots:
|
||||
qrcode-terminal: 0.12.0
|
||||
sharp: 0.34.5
|
||||
sqlite-vec: 0.1.7-alpha.2
|
||||
strip-ansi: 7.2.0
|
||||
tar: 7.5.10
|
||||
tslog: 4.10.2
|
||||
undici: 7.22.0
|
||||
ws: 8.19.0
|
||||
yaml: 2.8.2
|
||||
zod: 4.3.6
|
||||
optionalDependencies:
|
||||
'@discordjs/opus': 0.10.0
|
||||
transitivePeerDependencies:
|
||||
- '@discordjs/opus'
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- '@types/express'
|
||||
- audio-decode
|
||||
@@ -14298,18 +13780,12 @@ snapshots:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
zod-to-json-schema@3.25.1(zod@3.25.76):
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
zod-to-json-schema@3.25.1(zod@4.3.6):
|
||||
dependencies:
|
||||
zod: 4.3.6
|
||||
|
||||
zod@3.25.75: {}
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zod@4.3.6: {}
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
||||
@@ -234,6 +234,7 @@ export class AcpSessionManager {
|
||||
sessionKey,
|
||||
agent,
|
||||
mode: input.mode,
|
||||
resumeSessionId: input.resumeSessionId,
|
||||
cwd: requestedCwd,
|
||||
}),
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
@@ -655,6 +656,7 @@ export class AcpSessionManager {
|
||||
for await (const event of runtime.runTurn({
|
||||
handle,
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
mode: input.mode,
|
||||
requestId: input.requestId,
|
||||
signal: combinedSignal,
|
||||
|
||||
@@ -43,14 +43,21 @@ export type AcpInitializeSessionInput = {
|
||||
sessionKey: string;
|
||||
agent: string;
|
||||
mode: AcpRuntimeSessionMode;
|
||||
resumeSessionId?: string;
|
||||
cwd?: string;
|
||||
backendId?: string;
|
||||
};
|
||||
|
||||
export type AcpTurnAttachment = {
|
||||
mediaType: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export type AcpRunTurnInput = {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
text: string;
|
||||
attachments?: AcpTurnAttachment[];
|
||||
mode: AcpRuntimePromptMode;
|
||||
requestId: string;
|
||||
signal?: AbortSignal;
|
||||
|
||||
18
src/acp/event-mapper.test.ts
Normal file
18
src/acp/event-mapper.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractToolCallLocations } from "./event-mapper.js";
|
||||
|
||||
describe("extractToolCallLocations", () => {
|
||||
it("enforces the global node visit cap across nested structures", () => {
|
||||
const nested = Array.from({ length: 20 }, (_, outer) =>
|
||||
Array.from({ length: 20 }, (_, inner) =>
|
||||
inner === 19 ? { path: `/tmp/file-${outer}.txt` } : { note: `${outer}-${inner}` },
|
||||
),
|
||||
);
|
||||
|
||||
const locations = extractToolCallLocations(nested);
|
||||
|
||||
expect(locations).toBeDefined();
|
||||
expect(locations?.length).toBeLessThan(20);
|
||||
expect(locations).not.toContainEqual({ path: "/tmp/file-19.txt" });
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { ContentBlock, ImageContent, ToolKind } from "@agentclientprotocol/sdk";
|
||||
import type {
|
||||
ContentBlock,
|
||||
ImageContent,
|
||||
ToolCallContent,
|
||||
ToolCallLocation,
|
||||
ToolKind,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
|
||||
export type GatewayAttachment = {
|
||||
type: string;
|
||||
@@ -6,6 +12,39 @@ export type GatewayAttachment = {
|
||||
content: string;
|
||||
};
|
||||
|
||||
const TOOL_LOCATION_PATH_KEYS = [
|
||||
"path",
|
||||
"filePath",
|
||||
"file_path",
|
||||
"targetPath",
|
||||
"target_path",
|
||||
"targetFile",
|
||||
"target_file",
|
||||
"sourcePath",
|
||||
"source_path",
|
||||
"destinationPath",
|
||||
"destination_path",
|
||||
"oldPath",
|
||||
"old_path",
|
||||
"newPath",
|
||||
"new_path",
|
||||
"outputPath",
|
||||
"output_path",
|
||||
"inputPath",
|
||||
"input_path",
|
||||
] as const;
|
||||
|
||||
const TOOL_LOCATION_LINE_KEYS = [
|
||||
"line",
|
||||
"lineNumber",
|
||||
"line_number",
|
||||
"startLine",
|
||||
"start_line",
|
||||
] as const;
|
||||
const TOOL_RESULT_PATH_MARKER_RE = /^(?:FILE|MEDIA):(.+)$/gm;
|
||||
const TOOL_LOCATION_MAX_DEPTH = 4;
|
||||
const TOOL_LOCATION_MAX_NODES = 100;
|
||||
|
||||
const INLINE_CONTROL_ESCAPE_MAP: Readonly<Record<string, string>> = {
|
||||
"\0": "\\0",
|
||||
"\r": "\\r",
|
||||
@@ -56,6 +95,152 @@ function escapeResourceTitle(value: string): string {
|
||||
return escapeInlineControlChars(value).replace(/[()[\]]/g, (char) => `\\${char}`);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeToolLocationPath(value: string): string | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.length > 4096 ||
|
||||
trimmed.includes("\u0000") ||
|
||||
trimmed.includes("\r") ||
|
||||
trimmed.includes("\n")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
return undefined;
|
||||
}
|
||||
if (/^file:\/\//i.test(trimmed)) {
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
return decodeURIComponent(parsed.pathname || "") || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeToolLocationLine(value: unknown): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const line = Math.floor(value);
|
||||
return line > 0 ? line : undefined;
|
||||
}
|
||||
|
||||
function extractToolLocationLine(record: Record<string, unknown>): number | undefined {
|
||||
for (const key of TOOL_LOCATION_LINE_KEYS) {
|
||||
const line = normalizeToolLocationLine(record[key]);
|
||||
if (line !== undefined) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function addToolLocation(
|
||||
locations: Map<string, ToolCallLocation>,
|
||||
rawPath: string,
|
||||
line?: number,
|
||||
): void {
|
||||
const path = normalizeToolLocationPath(rawPath);
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
for (const [existingKey, existing] of locations.entries()) {
|
||||
if (existing.path !== path) {
|
||||
continue;
|
||||
}
|
||||
if (line === undefined || existing.line === line) {
|
||||
return;
|
||||
}
|
||||
if (existing.line === undefined) {
|
||||
locations.delete(existingKey);
|
||||
}
|
||||
}
|
||||
const locationKey = `${path}:${line ?? ""}`;
|
||||
if (locations.has(locationKey)) {
|
||||
return;
|
||||
}
|
||||
locations.set(locationKey, line ? { path, line } : { path });
|
||||
}
|
||||
|
||||
function collectLocationsFromTextMarkers(
|
||||
text: string,
|
||||
locations: Map<string, ToolCallLocation>,
|
||||
): void {
|
||||
for (const match of text.matchAll(TOOL_RESULT_PATH_MARKER_RE)) {
|
||||
const candidate = match[1]?.trim();
|
||||
if (candidate) {
|
||||
addToolLocation(locations, candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectToolLocations(
|
||||
value: unknown,
|
||||
locations: Map<string, ToolCallLocation>,
|
||||
state: { visited: number },
|
||||
depth: number,
|
||||
): void {
|
||||
if (state.visited >= TOOL_LOCATION_MAX_NODES || depth > TOOL_LOCATION_MAX_DEPTH) {
|
||||
return;
|
||||
}
|
||||
state.visited += 1;
|
||||
|
||||
if (typeof value === "string") {
|
||||
collectLocationsFromTextMarkers(value, locations);
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
collectToolLocations(item, locations, state, depth + 1);
|
||||
if (state.visited >= TOOL_LOCATION_MAX_NODES) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const line = extractToolLocationLine(record);
|
||||
for (const key of TOOL_LOCATION_PATH_KEYS) {
|
||||
const rawPath = record[key];
|
||||
if (typeof rawPath === "string") {
|
||||
addToolLocation(locations, rawPath, line);
|
||||
}
|
||||
}
|
||||
|
||||
const content = Array.isArray(record.content) ? record.content : undefined;
|
||||
if (content) {
|
||||
for (const block of content) {
|
||||
const entry = asRecord(block);
|
||||
if (entry?.type === "text" && typeof entry.text === "string") {
|
||||
collectLocationsFromTextMarkers(entry.text, locations);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, nested] of Object.entries(record)) {
|
||||
if (key === "content") {
|
||||
continue;
|
||||
}
|
||||
collectToolLocations(nested, locations, state, depth + 1);
|
||||
if (state.visited >= TOOL_LOCATION_MAX_NODES) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function extractTextFromPrompt(prompt: ContentBlock[], maxBytes?: number): string {
|
||||
const parts: string[] = [];
|
||||
// Track accumulated byte count per block to catch oversized prompts before full concatenation
|
||||
@@ -152,3 +337,74 @@ export function inferToolKind(name?: string): ToolKind {
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
export function extractToolCallContent(value: unknown): ToolCallContent[] | undefined {
|
||||
if (typeof value === "string") {
|
||||
return value.trim()
|
||||
? [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: value,
|
||||
},
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const contents: ToolCallContent[] = [];
|
||||
const blocks = Array.isArray(record.content) ? record.content : [];
|
||||
for (const block of blocks) {
|
||||
const entry = asRecord(block);
|
||||
if (entry?.type === "text" && typeof entry.text === "string" && entry.text.trim()) {
|
||||
contents.push({
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: entry.text,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (contents.length > 0) {
|
||||
return contents;
|
||||
}
|
||||
|
||||
const fallbackText =
|
||||
typeof record.text === "string"
|
||||
? record.text
|
||||
: typeof record.message === "string"
|
||||
? record.message
|
||||
: typeof record.error === "string"
|
||||
? record.error
|
||||
: undefined;
|
||||
|
||||
if (!fallbackText?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: fallbackText,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function extractToolCallLocations(...values: unknown[]): ToolCallLocation[] | undefined {
|
||||
const locations = new Map<string, ToolCallLocation>();
|
||||
for (const value of values) {
|
||||
collectToolLocations(value, locations, { visited: 0 }, 0);
|
||||
}
|
||||
return locations.size > 0 ? [...locations.values()] : undefined;
|
||||
}
|
||||
|
||||
@@ -35,13 +35,20 @@ export type AcpRuntimeEnsureInput = {
|
||||
sessionKey: string;
|
||||
agent: string;
|
||||
mode: AcpRuntimeSessionMode;
|
||||
resumeSessionId?: string;
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type AcpRuntimeTurnAttachment = {
|
||||
mediaType: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export type AcpRuntimeTurnInput = {
|
||||
handle: AcpRuntimeHandle;
|
||||
text: string;
|
||||
attachments?: AcpRuntimeTurnAttachment[];
|
||||
mode: AcpRuntimePromptMode;
|
||||
requestId: string;
|
||||
signal?: AbortSignal;
|
||||
|
||||
@@ -2,9 +2,12 @@ import type {
|
||||
LoadSessionRequest,
|
||||
NewSessionRequest,
|
||||
PromptRequest,
|
||||
SetSessionConfigOptionRequest,
|
||||
SetSessionModeRequest,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayClient } from "../gateway/client.js";
|
||||
import type { EventFrame } from "../gateway/protocol/index.js";
|
||||
import { createInMemorySessionStore } from "./session.js";
|
||||
import { AcpGatewayAgent } from "./translator.js";
|
||||
import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js";
|
||||
@@ -38,6 +41,65 @@ function createPromptRequest(
|
||||
} as unknown as PromptRequest;
|
||||
}
|
||||
|
||||
function createSetSessionModeRequest(sessionId: string, modeId: string): SetSessionModeRequest {
|
||||
return {
|
||||
sessionId,
|
||||
modeId,
|
||||
_meta: {},
|
||||
} as unknown as SetSessionModeRequest;
|
||||
}
|
||||
|
||||
function createSetSessionConfigOptionRequest(
|
||||
sessionId: string,
|
||||
configId: string,
|
||||
value: string,
|
||||
): SetSessionConfigOptionRequest {
|
||||
return {
|
||||
sessionId,
|
||||
configId,
|
||||
value,
|
||||
_meta: {},
|
||||
} as unknown as SetSessionConfigOptionRequest;
|
||||
}
|
||||
|
||||
function createToolEvent(params: {
|
||||
sessionKey: string;
|
||||
phase: "start" | "update" | "result";
|
||||
toolCallId: string;
|
||||
name: string;
|
||||
args?: Record<string, unknown>;
|
||||
partialResult?: unknown;
|
||||
result?: unknown;
|
||||
isError?: boolean;
|
||||
}): EventFrame {
|
||||
return {
|
||||
event: "agent",
|
||||
payload: {
|
||||
sessionKey: params.sessionKey,
|
||||
stream: "tool",
|
||||
data: {
|
||||
phase: params.phase,
|
||||
toolCallId: params.toolCallId,
|
||||
name: params.name,
|
||||
args: params.args,
|
||||
partialResult: params.partialResult,
|
||||
result: params.result,
|
||||
isError: params.isError,
|
||||
},
|
||||
},
|
||||
} as unknown as EventFrame;
|
||||
}
|
||||
|
||||
function createChatFinalEvent(sessionKey: string): EventFrame {
|
||||
return {
|
||||
event: "chat",
|
||||
payload: {
|
||||
sessionKey,
|
||||
state: "final",
|
||||
},
|
||||
} as unknown as EventFrame;
|
||||
}
|
||||
|
||||
async function expectOversizedPromptRejected(params: { sessionId: string; text: string }) {
|
||||
const request = vi.fn(async () => ({ ok: true })) as GatewayClient["request"];
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
@@ -97,6 +159,732 @@ describe("acp session creation rate limit", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("acp unsupported bridge session setup", () => {
|
||||
it("rejects per-session MCP servers on newSession", async () => {
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
const connection = createAcpConnection();
|
||||
const sessionUpdate = connection.__sessionUpdateMock;
|
||||
const agent = new AcpGatewayAgent(connection, createAcpGateway(), {
|
||||
sessionStore,
|
||||
});
|
||||
|
||||
await expect(
|
||||
agent.newSession({
|
||||
...createNewSessionRequest(),
|
||||
mcpServers: [{ name: "docs", command: "mcp-docs" }] as never[],
|
||||
}),
|
||||
).rejects.toThrow(/does not support per-session MCP servers/i);
|
||||
|
||||
expect(sessionStore.hasSession("docs-session")).toBe(false);
|
||||
expect(sessionUpdate).not.toHaveBeenCalled();
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
});
|
||||
|
||||
it("rejects per-session MCP servers on loadSession", async () => {
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
const connection = createAcpConnection();
|
||||
const sessionUpdate = connection.__sessionUpdateMock;
|
||||
const agent = new AcpGatewayAgent(connection, createAcpGateway(), {
|
||||
sessionStore,
|
||||
});
|
||||
|
||||
await expect(
|
||||
agent.loadSession({
|
||||
...createLoadSessionRequest("docs-session"),
|
||||
mcpServers: [{ name: "docs", command: "mcp-docs" }] as never[],
|
||||
}),
|
||||
).rejects.toThrow(/does not support per-session MCP servers/i);
|
||||
|
||||
expect(sessionStore.hasSession("docs-session")).toBe(false);
|
||||
expect(sessionUpdate).not.toHaveBeenCalled();
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
});
|
||||
});
|
||||
|
||||
describe("acp session UX bridge behavior", () => {
|
||||
it("returns initial modes and thought-level config options for new sessions", async () => {
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(), {
|
||||
sessionStore,
|
||||
});
|
||||
|
||||
const result = await agent.newSession(createNewSessionRequest());
|
||||
|
||||
expect(result.modes?.currentModeId).toBe("adaptive");
|
||||
expect(result.modes?.availableModes.map((mode) => mode.id)).toContain("adaptive");
|
||||
expect(result.configOptions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "thought_level",
|
||||
currentValue: "adaptive",
|
||||
category: "thought_level",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "verbose_level",
|
||||
currentValue: "off",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "reasoning_level",
|
||||
currentValue: "off",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "response_usage",
|
||||
currentValue: "off",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "elevated_level",
|
||||
currentValue: "off",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
});
|
||||
|
||||
it("replays user and assistant text history on loadSession and returns initial controls", async () => {
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
const connection = createAcpConnection();
|
||||
const sessionUpdate = connection.__sessionUpdateMock;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
path: "/tmp/sessions.json",
|
||||
count: 1,
|
||||
defaults: {
|
||||
modelProvider: null,
|
||||
model: null,
|
||||
contextTokens: null,
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
key: "agent:main:work",
|
||||
label: "main-work",
|
||||
displayName: "Main work",
|
||||
derivedTitle: "Fix ACP bridge",
|
||||
kind: "direct",
|
||||
updatedAt: 1_710_000_000_000,
|
||||
thinkingLevel: "high",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
verboseLevel: "full",
|
||||
reasoningLevel: "stream",
|
||||
responseUsage: "tokens",
|
||||
elevatedLevel: "ask",
|
||||
totalTokens: 4096,
|
||||
totalTokensFresh: true,
|
||||
contextTokens: 8192,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "sessions.get") {
|
||||
return {
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "Question" }] },
|
||||
{ role: "assistant", content: [{ type: "text", text: "Answer" }] },
|
||||
{ role: "system", content: [{ type: "text", text: "ignore me" }] },
|
||||
{ role: "assistant", content: [{ type: "image", image: "skip" }] },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}) as GatewayClient["request"];
|
||||
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
|
||||
sessionStore,
|
||||
});
|
||||
|
||||
const result = await agent.loadSession(createLoadSessionRequest("agent:main:work"));
|
||||
|
||||
expect(result.modes?.currentModeId).toBe("high");
|
||||
expect(result.modes?.availableModes.map((mode) => mode.id)).toContain("xhigh");
|
||||
expect(result.configOptions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "thought_level",
|
||||
currentValue: "high",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "verbose_level",
|
||||
currentValue: "full",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "reasoning_level",
|
||||
currentValue: "stream",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "response_usage",
|
||||
currentValue: "tokens",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "elevated_level",
|
||||
currentValue: "ask",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: "agent:main:work",
|
||||
update: {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text: "Question" },
|
||||
},
|
||||
});
|
||||
expect(sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: "agent:main:work",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "Answer" },
|
||||
},
|
||||
});
|
||||
expect(sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: "agent:main:work",
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: "available_commands_update",
|
||||
}),
|
||||
});
|
||||
expect(sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: "agent:main:work",
|
||||
update: {
|
||||
sessionUpdate: "session_info_update",
|
||||
title: "Fix ACP bridge",
|
||||
updatedAt: "2024-03-09T16:00:00.000Z",
|
||||
},
|
||||
});
|
||||
expect(sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: "agent:main:work",
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used: 4096,
|
||||
size: 8192,
|
||||
_meta: {
|
||||
source: "gateway-session-store",
|
||||
approximate: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
});
|
||||
|
||||
it("falls back to an empty transcript when sessions.get fails during loadSession", async () => {
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
const connection = createAcpConnection();
|
||||
const sessionUpdate = connection.__sessionUpdateMock;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
path: "/tmp/sessions.json",
|
||||
count: 1,
|
||||
defaults: {
|
||||
modelProvider: null,
|
||||
model: null,
|
||||
contextTokens: null,
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
key: "agent:main:recover",
|
||||
label: "recover",
|
||||
displayName: "Recover session",
|
||||
kind: "direct",
|
||||
updatedAt: 1_710_000_000_000,
|
||||
thinkingLevel: "adaptive",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "sessions.get") {
|
||||
throw new Error("sessions.get unavailable");
|
||||
}
|
||||
return { ok: true };
|
||||
}) as GatewayClient["request"];
|
||||
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
|
||||
sessionStore,
|
||||
});
|
||||
|
||||
const result = await agent.loadSession(createLoadSessionRequest("agent:main:recover"));
|
||||
|
||||
expect(result.modes?.currentModeId).toBe("adaptive");
|
||||
expect(sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: "agent:main:recover",
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: "available_commands_update",
|
||||
}),
|
||||
});
|
||||
expect(sessionUpdate).not.toHaveBeenCalledWith({
|
||||
sessionId: "agent:main:recover",
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: "user_message_chunk",
|
||||
}),
|
||||
});
|
||||
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
});
|
||||
});
|
||||
|
||||
describe("acp setSessionMode bridge behavior", () => {
|
||||
it("surfaces gateway mode patch failures instead of succeeding silently", async () => {
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.patch") {
|
||||
throw new Error("gateway rejected mode");
|
||||
}
|
||||
return { ok: true };
|
||||
}) as GatewayClient["request"];
|
||||
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
|
||||
sessionStore,
|
||||
});
|
||||
|
||||
await agent.loadSession(createLoadSessionRequest("mode-session"));
|
||||
|
||||
await expect(
|
||||
agent.setSessionMode(createSetSessionModeRequest("mode-session", "high")),
|
||||
).rejects.toThrow(/gateway rejected mode/i);
|
||||
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
});
|
||||
|
||||
it("emits current mode and thought-level config updates after a successful mode change", async () => {
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
const connection = createAcpConnection();
|
||||
const sessionUpdate = connection.__sessionUpdateMock;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
path: "/tmp/sessions.json",
|
||||
count: 1,
|
||||
defaults: {
|
||||
modelProvider: null,
|
||||
model: null,
|
||||
contextTokens: null,
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
key: "mode-session",
|
||||
kind: "direct",
|
||||
updatedAt: Date.now(),
|
||||
thinkingLevel: "high",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}) as GatewayClient["request"];
|
||||
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
|
||||
sessionStore,
|
||||
});
|
||||
|
||||
await agent.loadSession(createLoadSessionRequest("mode-session"));
|
||||
sessionUpdate.mockClear();
|
||||
|
||||
await agent.setSessionMode(createSetSessionModeRequest("mode-session", "high"));
|
||||
|
||||
expect(sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: "mode-session",
|
||||
update: {
|
||||
sessionUpdate: "current_mode_update",
|
||||
currentModeId: "high",
|
||||
},
|
||||
});
|
||||
expect(sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: "mode-session",
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "thought_level",
|
||||
currentValue: "high",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
});
|
||||
});
|
||||
|
||||
describe("acp setSessionConfigOption bridge behavior", () => {
|
||||
it("updates the thought-level config option and returns refreshed options", async () => {
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
const connection = createAcpConnection();
|
||||
const sessionUpdate = connection.__sessionUpdateMock;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
path: "/tmp/sessions.json",
|
||||
count: 1,
|
||||
defaults: {
|
||||
modelProvider: null,
|
||||
model: null,
|
||||
contextTokens: null,
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
key: "config-session",
|
||||
kind: "direct",
|
||||
updatedAt: Date.now(),
|
||||
thinkingLevel: "minimal",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}) as GatewayClient["request"];
|
||||
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
|
||||
sessionStore,
|
||||
});
|
||||
|
||||
await agent.loadSession(createLoadSessionRequest("config-session"));
|
||||
sessionUpdate.mockClear();
|
||||
|
||||
const result = await agent.setSessionConfigOption(
|
||||
createSetSessionConfigOptionRequest("config-session", "thought_level", "minimal"),
|
||||
);
|
||||
|
||||
expect(result.configOptions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "thought_level",
|
||||
currentValue: "minimal",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: "config-session",
|
||||
update: {
|
||||
sessionUpdate: "current_mode_update",
|
||||
currentModeId: "minimal",
|
||||
},
|
||||
});
|
||||
expect(sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: "config-session",
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "thought_level",
|
||||
currentValue: "minimal",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
});
|
||||
|
||||
it("updates non-mode ACP config options through gateway session patches", async () => {
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
const connection = createAcpConnection();
|
||||
const sessionUpdate = connection.__sessionUpdateMock;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
path: "/tmp/sessions.json",
|
||||
count: 1,
|
||||
defaults: {
|
||||
modelProvider: null,
|
||||
model: null,
|
||||
contextTokens: null,
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
key: "reasoning-session",
|
||||
kind: "direct",
|
||||
updatedAt: Date.now(),
|
||||
thinkingLevel: "minimal",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
reasoningLevel: "stream",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}) as GatewayClient["request"];
|
||||
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
|
||||
sessionStore,
|
||||
});
|
||||
|
||||
await agent.loadSession(createLoadSessionRequest("reasoning-session"));
|
||||
sessionUpdate.mockClear();
|
||||
|
||||
const result = await agent.setSessionConfigOption(
|
||||
createSetSessionConfigOptionRequest("reasoning-session", "reasoning_level", "stream"),
|
||||
);
|
||||
|
||||
expect(result.configOptions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "reasoning_level",
|
||||
currentValue: "stream",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: "reasoning-session",
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "reasoning_level",
|
||||
currentValue: "stream",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
});
|
||||
});
|
||||
|
||||
describe("acp tool streaming bridge behavior", () => {
|
||||
it("maps Gateway tool partial output and file locations into ACP tool updates", async () => {
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
const connection = createAcpConnection();
|
||||
const sessionUpdate = connection.__sessionUpdateMock;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "chat.send") {
|
||||
return new Promise(() => {});
|
||||
}
|
||||
return { ok: true };
|
||||
}) as GatewayClient["request"];
|
||||
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
|
||||
sessionStore,
|
||||
});
|
||||
|
||||
await agent.loadSession(createLoadSessionRequest("tool-session"));
|
||||
sessionUpdate.mockClear();
|
||||
|
||||
const promptPromise = agent.prompt(createPromptRequest("tool-session", "Inspect app.ts"));
|
||||
|
||||
await agent.handleGatewayEvent(
|
||||
createToolEvent({
|
||||
sessionKey: "tool-session",
|
||||
phase: "start",
|
||||
toolCallId: "tool-1",
|
||||
name: "read",
|
||||
args: { path: "src/app.ts", line: 12 },
|
||||
}),
|
||||
);
|
||||
await agent.handleGatewayEvent(
|
||||
createToolEvent({
|
||||
sessionKey: "tool-session",
|
||||
phase: "update",
|
||||
toolCallId: "tool-1",
|
||||
name: "read",
|
||||
partialResult: {
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
details: { path: "src/app.ts" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await agent.handleGatewayEvent(
|
||||
createToolEvent({
|
||||
sessionKey: "tool-session",
|
||||
phase: "result",
|
||||
toolCallId: "tool-1",
|
||||
name: "read",
|
||||
result: {
|
||||
content: [{ type: "text", text: "FILE:src/app.ts" }],
|
||||
details: { path: "src/app.ts" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await agent.handleGatewayEvent(createChatFinalEvent("tool-session"));
|
||||
await promptPromise;
|
||||
|
||||
expect(sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: "tool-session",
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "tool-1",
|
||||
title: "read: path: src/app.ts, line: 12",
|
||||
status: "in_progress",
|
||||
rawInput: { path: "src/app.ts", line: 12 },
|
||||
kind: "read",
|
||||
locations: [{ path: "src/app.ts", line: 12 }],
|
||||
},
|
||||
});
|
||||
expect(sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: "tool-session",
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "tool-1",
|
||||
status: "in_progress",
|
||||
rawOutput: {
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
details: { path: "src/app.ts" },
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "partial output" },
|
||||
},
|
||||
],
|
||||
locations: [{ path: "src/app.ts", line: 12 }],
|
||||
},
|
||||
});
|
||||
expect(sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: "tool-session",
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "tool-1",
|
||||
status: "completed",
|
||||
rawOutput: {
|
||||
content: [{ type: "text", text: "FILE:src/app.ts" }],
|
||||
details: { path: "src/app.ts" },
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "FILE:src/app.ts" },
|
||||
},
|
||||
],
|
||||
locations: [{ path: "src/app.ts", line: 12 }],
|
||||
},
|
||||
});
|
||||
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
});
|
||||
});
|
||||
|
||||
describe("acp session metadata and usage updates", () => {
|
||||
it("emits a fresh usage snapshot after prompt completion when gateway totals are available", async () => {
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
const connection = createAcpConnection();
|
||||
const sessionUpdate = connection.__sessionUpdateMock;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
path: "/tmp/sessions.json",
|
||||
count: 1,
|
||||
defaults: {
|
||||
modelProvider: null,
|
||||
model: null,
|
||||
contextTokens: null,
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
key: "usage-session",
|
||||
displayName: "Usage session",
|
||||
kind: "direct",
|
||||
updatedAt: 1_710_000_123_000,
|
||||
thinkingLevel: "adaptive",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
totalTokens: 1200,
|
||||
totalTokensFresh: true,
|
||||
contextTokens: 4000,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "chat.send") {
|
||||
return new Promise(() => {});
|
||||
}
|
||||
return { ok: true };
|
||||
}) as GatewayClient["request"];
|
||||
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
|
||||
sessionStore,
|
||||
});
|
||||
|
||||
await agent.loadSession(createLoadSessionRequest("usage-session"));
|
||||
sessionUpdate.mockClear();
|
||||
|
||||
const promptPromise = agent.prompt(createPromptRequest("usage-session", "hello"));
|
||||
await agent.handleGatewayEvent(createChatFinalEvent("usage-session"));
|
||||
await promptPromise;
|
||||
|
||||
expect(sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: "usage-session",
|
||||
update: {
|
||||
sessionUpdate: "session_info_update",
|
||||
title: "Usage session",
|
||||
updatedAt: "2024-03-09T16:02:03.000Z",
|
||||
},
|
||||
});
|
||||
expect(sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: "usage-session",
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used: 1200,
|
||||
size: 4000,
|
||||
_meta: {
|
||||
source: "gateway-session-store",
|
||||
approximate: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
});
|
||||
|
||||
it("still resolves prompts when snapshot updates fail after completion", async () => {
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
const connection = createAcpConnection();
|
||||
const sessionUpdate = connection.__sessionUpdateMock;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
path: "/tmp/sessions.json",
|
||||
count: 1,
|
||||
defaults: {
|
||||
modelProvider: null,
|
||||
model: null,
|
||||
contextTokens: null,
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
key: "usage-session",
|
||||
displayName: "Usage session",
|
||||
kind: "direct",
|
||||
updatedAt: 1_710_000_123_000,
|
||||
thinkingLevel: "adaptive",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
totalTokens: 1200,
|
||||
totalTokensFresh: true,
|
||||
contextTokens: 4000,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "chat.send") {
|
||||
return new Promise(() => {});
|
||||
}
|
||||
return { ok: true };
|
||||
}) as GatewayClient["request"];
|
||||
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
|
||||
sessionStore,
|
||||
});
|
||||
|
||||
await agent.loadSession(createLoadSessionRequest("usage-session"));
|
||||
sessionUpdate.mockClear();
|
||||
sessionUpdate.mockRejectedValueOnce(new Error("session update transport failed"));
|
||||
|
||||
const promptPromise = agent.prompt(createPromptRequest("usage-session", "hello"));
|
||||
await agent.handleGatewayEvent(createChatFinalEvent("usage-session"));
|
||||
|
||||
await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" });
|
||||
const session = sessionStore.getSession("usage-session");
|
||||
expect(session?.activeRunId).toBeNull();
|
||||
expect(session?.abortController).toBeNull();
|
||||
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
});
|
||||
});
|
||||
|
||||
describe("acp prompt size hardening", () => {
|
||||
it("rejects oversized prompt blocks without leaking active runs", async () => {
|
||||
await expectOversizedPromptRejected({
|
||||
|
||||
@@ -2,10 +2,16 @@ import type { AgentSideConnection } from "@agentclientprotocol/sdk";
|
||||
import { vi } from "vitest";
|
||||
import type { GatewayClient } from "../gateway/client.js";
|
||||
|
||||
export function createAcpConnection(): AgentSideConnection {
|
||||
export type TestAcpConnection = AgentSideConnection & {
|
||||
__sessionUpdateMock: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
export function createAcpConnection(): TestAcpConnection {
|
||||
const sessionUpdate = vi.fn(async () => {});
|
||||
return {
|
||||
sessionUpdate: vi.fn(async () => {}),
|
||||
} as unknown as AgentSideConnection;
|
||||
sessionUpdate,
|
||||
__sessionUpdateMock: sessionUpdate,
|
||||
} as unknown as TestAcpConnection;
|
||||
}
|
||||
|
||||
export function createAcpGateway(
|
||||
|
||||
@@ -16,14 +16,21 @@ import type {
|
||||
NewSessionResponse,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
SessionConfigOption,
|
||||
SessionModeState,
|
||||
SetSessionConfigOptionRequest,
|
||||
SetSessionConfigOptionResponse,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
StopReason,
|
||||
ToolCallLocation,
|
||||
ToolKind,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
|
||||
import { listThinkingLevels } from "../auto-reply/thinking.js";
|
||||
import type { GatewayClient } from "../gateway/client.js";
|
||||
import type { EventFrame } from "../gateway/protocol/index.js";
|
||||
import type { SessionsListResult } from "../gateway/session-utils.js";
|
||||
import type { GatewaySessionRow, SessionsListResult } from "../gateway/session-utils.js";
|
||||
import {
|
||||
createFixedWindowRateLimiter,
|
||||
type FixedWindowRateLimiter,
|
||||
@@ -32,6 +39,8 @@ import { shortenHomePath } from "../utils.js";
|
||||
import { getAvailableCommands } from "./commands.js";
|
||||
import {
|
||||
extractAttachmentsFromPrompt,
|
||||
extractToolCallContent,
|
||||
extractToolCallLocations,
|
||||
extractTextFromPrompt,
|
||||
formatToolTitle,
|
||||
inferToolKind,
|
||||
@@ -43,6 +52,12 @@ import { ACP_AGENT_INFO, type AcpServerOptions } from "./types.js";
|
||||
|
||||
// Maximum allowed prompt size (2MB) to prevent DoS via memory exhaustion (CWE-400, GHSA-cxpw-2g23-2vgw)
|
||||
const MAX_PROMPT_BYTES = 2 * 1024 * 1024;
|
||||
const ACP_THOUGHT_LEVEL_CONFIG_ID = "thought_level";
|
||||
const ACP_VERBOSE_LEVEL_CONFIG_ID = "verbose_level";
|
||||
const ACP_REASONING_LEVEL_CONFIG_ID = "reasoning_level";
|
||||
const ACP_RESPONSE_USAGE_CONFIG_ID = "response_usage";
|
||||
const ACP_ELEVATED_LEVEL_CONFIG_ID = "elevated_level";
|
||||
const ACP_LOAD_SESSION_REPLAY_LIMIT = 1_000_000;
|
||||
|
||||
type PendingPrompt = {
|
||||
sessionId: string;
|
||||
@@ -52,16 +67,240 @@ type PendingPrompt = {
|
||||
reject: (err: Error) => void;
|
||||
sentTextLength?: number;
|
||||
sentText?: string;
|
||||
toolCalls?: Set<string>;
|
||||
toolCalls?: Map<string, PendingToolCall>;
|
||||
};
|
||||
|
||||
type PendingToolCall = {
|
||||
kind: ToolKind;
|
||||
locations?: ToolCallLocation[];
|
||||
rawInput?: Record<string, unknown>;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type AcpGatewayAgentOptions = AcpServerOptions & {
|
||||
sessionStore?: AcpSessionStore;
|
||||
};
|
||||
|
||||
type GatewaySessionPresentationRow = Pick<
|
||||
GatewaySessionRow,
|
||||
| "displayName"
|
||||
| "label"
|
||||
| "derivedTitle"
|
||||
| "updatedAt"
|
||||
| "thinkingLevel"
|
||||
| "modelProvider"
|
||||
| "model"
|
||||
| "verboseLevel"
|
||||
| "reasoningLevel"
|
||||
| "responseUsage"
|
||||
| "elevatedLevel"
|
||||
| "totalTokens"
|
||||
| "totalTokensFresh"
|
||||
| "contextTokens"
|
||||
>;
|
||||
|
||||
type SessionPresentation = {
|
||||
configOptions: SessionConfigOption[];
|
||||
modes: SessionModeState;
|
||||
};
|
||||
|
||||
type SessionMetadata = {
|
||||
title?: string | null;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
type SessionUsageSnapshot = {
|
||||
size: number;
|
||||
used: number;
|
||||
};
|
||||
|
||||
type SessionSnapshot = SessionPresentation & {
|
||||
metadata?: SessionMetadata;
|
||||
usage?: SessionUsageSnapshot;
|
||||
};
|
||||
|
||||
type GatewayTranscriptMessage = {
|
||||
role?: unknown;
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
const SESSION_CREATE_RATE_LIMIT_DEFAULT_MAX_REQUESTS = 120;
|
||||
const SESSION_CREATE_RATE_LIMIT_DEFAULT_WINDOW_MS = 10_000;
|
||||
|
||||
function formatThinkingLevelName(level: string): string {
|
||||
switch (level) {
|
||||
case "xhigh":
|
||||
return "Extra High";
|
||||
case "adaptive":
|
||||
return "Adaptive";
|
||||
default:
|
||||
return level.length > 0 ? `${level[0].toUpperCase()}${level.slice(1)}` : "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function buildThinkingModeDescription(level: string): string | undefined {
|
||||
if (level === "adaptive") {
|
||||
return "Use the Gateway session default thought level.";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatConfigValueName(value: string): string {
|
||||
switch (value) {
|
||||
case "xhigh":
|
||||
return "Extra High";
|
||||
default:
|
||||
return value.length > 0 ? `${value[0].toUpperCase()}${value.slice(1)}` : "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function buildSelectConfigOption(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
currentValue: string;
|
||||
values: readonly string[];
|
||||
category?: string;
|
||||
}): SessionConfigOption {
|
||||
return {
|
||||
type: "select",
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
category: params.category,
|
||||
description: params.description,
|
||||
currentValue: params.currentValue,
|
||||
options: params.values.map((value) => ({
|
||||
value,
|
||||
name: formatConfigValueName(value),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSessionPresentation(params: {
|
||||
row?: GatewaySessionPresentationRow;
|
||||
overrides?: Partial<GatewaySessionPresentationRow>;
|
||||
}): SessionPresentation {
|
||||
const row = {
|
||||
...params.row,
|
||||
...params.overrides,
|
||||
};
|
||||
const availableLevelIds: string[] = [...listThinkingLevels(row.modelProvider, row.model)];
|
||||
const currentModeId = row.thinkingLevel?.trim() || "adaptive";
|
||||
if (!availableLevelIds.includes(currentModeId)) {
|
||||
availableLevelIds.push(currentModeId);
|
||||
}
|
||||
|
||||
const modes: SessionModeState = {
|
||||
currentModeId,
|
||||
availableModes: availableLevelIds.map((level) => ({
|
||||
id: level,
|
||||
name: formatThinkingLevelName(level),
|
||||
description: buildThinkingModeDescription(level),
|
||||
})),
|
||||
};
|
||||
|
||||
const configOptions: SessionConfigOption[] = [
|
||||
buildSelectConfigOption({
|
||||
id: ACP_THOUGHT_LEVEL_CONFIG_ID,
|
||||
name: "Thought level",
|
||||
category: "thought_level",
|
||||
description:
|
||||
"Controls how much deliberate reasoning OpenClaw requests from the Gateway model.",
|
||||
currentValue: currentModeId,
|
||||
values: availableLevelIds,
|
||||
}),
|
||||
buildSelectConfigOption({
|
||||
id: ACP_VERBOSE_LEVEL_CONFIG_ID,
|
||||
name: "Tool verbosity",
|
||||
description:
|
||||
"Controls how much tool progress and output detail OpenClaw keeps enabled for the session.",
|
||||
currentValue: row.verboseLevel?.trim() || "off",
|
||||
values: ["off", "on", "full"],
|
||||
}),
|
||||
buildSelectConfigOption({
|
||||
id: ACP_REASONING_LEVEL_CONFIG_ID,
|
||||
name: "Reasoning stream",
|
||||
description: "Controls whether reasoning-capable models emit reasoning text for the session.",
|
||||
currentValue: row.reasoningLevel?.trim() || "off",
|
||||
values: ["off", "on", "stream"],
|
||||
}),
|
||||
buildSelectConfigOption({
|
||||
id: ACP_RESPONSE_USAGE_CONFIG_ID,
|
||||
name: "Usage detail",
|
||||
description:
|
||||
"Controls how much usage information OpenClaw attaches to responses for the session.",
|
||||
currentValue: row.responseUsage?.trim() || "off",
|
||||
values: ["off", "tokens", "full"],
|
||||
}),
|
||||
buildSelectConfigOption({
|
||||
id: ACP_ELEVATED_LEVEL_CONFIG_ID,
|
||||
name: "Elevated actions",
|
||||
description: "Controls how aggressively the session allows elevated execution behavior.",
|
||||
currentValue: row.elevatedLevel?.trim() || "off",
|
||||
values: ["off", "on", "ask", "full"],
|
||||
}),
|
||||
];
|
||||
|
||||
return { configOptions, modes };
|
||||
}
|
||||
|
||||
function extractReplayText(content: unknown): string | undefined {
|
||||
if (typeof content === "string") {
|
||||
return content.length > 0 ? content : undefined;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return undefined;
|
||||
}
|
||||
const text = content
|
||||
.map((block) => {
|
||||
if (!block || typeof block !== "object" || Array.isArray(block)) {
|
||||
return "";
|
||||
}
|
||||
const typedBlock = block as { type?: unknown; text?: unknown };
|
||||
return typedBlock.type === "text" && typeof typedBlock.text === "string"
|
||||
? typedBlock.text
|
||||
: "";
|
||||
})
|
||||
.join("");
|
||||
return text.length > 0 ? text : undefined;
|
||||
}
|
||||
|
||||
function buildSessionMetadata(params: {
|
||||
row?: GatewaySessionPresentationRow;
|
||||
sessionKey: string;
|
||||
}): SessionMetadata {
|
||||
const title =
|
||||
params.row?.derivedTitle?.trim() ||
|
||||
params.row?.displayName?.trim() ||
|
||||
params.row?.label?.trim() ||
|
||||
params.sessionKey;
|
||||
const updatedAt =
|
||||
typeof params.row?.updatedAt === "number" && Number.isFinite(params.row.updatedAt)
|
||||
? new Date(params.row.updatedAt).toISOString()
|
||||
: null;
|
||||
return { title, updatedAt };
|
||||
}
|
||||
|
||||
function buildSessionUsageSnapshot(
|
||||
row?: GatewaySessionPresentationRow,
|
||||
): SessionUsageSnapshot | undefined {
|
||||
const totalTokens = row?.totalTokens;
|
||||
const contextTokens = row?.contextTokens;
|
||||
if (
|
||||
row?.totalTokensFresh !== true ||
|
||||
typeof totalTokens !== "number" ||
|
||||
!Number.isFinite(totalTokens) ||
|
||||
typeof contextTokens !== "number" ||
|
||||
!Number.isFinite(contextTokens) ||
|
||||
contextTokens <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const size = Math.max(0, Math.floor(contextTokens));
|
||||
const used = Math.max(0, Math.min(Math.floor(totalTokens), size));
|
||||
return { size, used };
|
||||
}
|
||||
|
||||
function buildSystemInputProvenance(originSessionId: string) {
|
||||
return {
|
||||
kind: "external_user" as const,
|
||||
@@ -170,9 +409,7 @@ export class AcpGatewayAgent implements Agent {
|
||||
}
|
||||
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
if (params.mcpServers.length > 0) {
|
||||
this.log(`ignoring ${params.mcpServers.length} MCP servers`);
|
||||
}
|
||||
this.assertSupportedSessionSetup(params.mcpServers);
|
||||
this.enforceSessionCreateRateLimit("newSession");
|
||||
|
||||
const sessionId = randomUUID();
|
||||
@@ -188,14 +425,21 @@ export class AcpGatewayAgent implements Agent {
|
||||
cwd: params.cwd,
|
||||
});
|
||||
this.log(`newSession: ${session.sessionId} -> ${session.sessionKey}`);
|
||||
const sessionSnapshot = await this.getSessionSnapshot(session.sessionKey);
|
||||
await this.sendSessionSnapshotUpdate(session.sessionId, sessionSnapshot, {
|
||||
includeControls: false,
|
||||
});
|
||||
await this.sendAvailableCommands(session.sessionId);
|
||||
return { sessionId: session.sessionId };
|
||||
const { configOptions, modes } = sessionSnapshot;
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
configOptions,
|
||||
modes,
|
||||
};
|
||||
}
|
||||
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
if (params.mcpServers.length > 0) {
|
||||
this.log(`ignoring ${params.mcpServers.length} MCP servers`);
|
||||
}
|
||||
this.assertSupportedSessionSetup(params.mcpServers);
|
||||
if (!this.sessionStore.hasSession(params.sessionId)) {
|
||||
this.enforceSessionCreateRateLimit("loadSession");
|
||||
}
|
||||
@@ -212,8 +456,20 @@ export class AcpGatewayAgent implements Agent {
|
||||
cwd: params.cwd,
|
||||
});
|
||||
this.log(`loadSession: ${session.sessionId} -> ${session.sessionKey}`);
|
||||
const [sessionSnapshot, transcript] = await Promise.all([
|
||||
this.getSessionSnapshot(session.sessionKey),
|
||||
this.getSessionTranscript(session.sessionKey).catch((err) => {
|
||||
this.log(`session transcript fallback for ${session.sessionKey}: ${String(err)}`);
|
||||
return [];
|
||||
}),
|
||||
]);
|
||||
await this.replaySessionTranscript(session.sessionId, transcript);
|
||||
await this.sendSessionSnapshotUpdate(session.sessionId, sessionSnapshot, {
|
||||
includeControls: false,
|
||||
});
|
||||
await this.sendAvailableCommands(session.sessionId);
|
||||
return {};
|
||||
const { configOptions, modes } = sessionSnapshot;
|
||||
return { configOptions, modes };
|
||||
}
|
||||
|
||||
async unstable_listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
|
||||
@@ -254,13 +510,52 @@ export class AcpGatewayAgent implements Agent {
|
||||
thinkingLevel: params.modeId,
|
||||
});
|
||||
this.log(`setSessionMode: ${session.sessionId} -> ${params.modeId}`);
|
||||
const sessionSnapshot = await this.getSessionSnapshot(session.sessionKey, {
|
||||
thinkingLevel: params.modeId,
|
||||
});
|
||||
await this.sendSessionSnapshotUpdate(session.sessionId, sessionSnapshot, {
|
||||
includeControls: true,
|
||||
});
|
||||
} catch (err) {
|
||||
this.log(`setSessionMode error: ${String(err)}`);
|
||||
throw err;
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async setSessionConfigOption(
|
||||
params: SetSessionConfigOptionRequest,
|
||||
): Promise<SetSessionConfigOptionResponse> {
|
||||
const session = this.sessionStore.getSession(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session ${params.sessionId} not found`);
|
||||
}
|
||||
const sessionPatch = this.resolveSessionConfigPatch(params.configId, params.value);
|
||||
|
||||
try {
|
||||
await this.gateway.request("sessions.patch", {
|
||||
key: session.sessionKey,
|
||||
...sessionPatch.patch,
|
||||
});
|
||||
this.log(
|
||||
`setSessionConfigOption: ${session.sessionId} -> ${params.configId}=${params.value}`,
|
||||
);
|
||||
const sessionSnapshot = await this.getSessionSnapshot(
|
||||
session.sessionKey,
|
||||
sessionPatch.overrides,
|
||||
);
|
||||
await this.sendSessionSnapshotUpdate(session.sessionId, sessionSnapshot, {
|
||||
includeControls: true,
|
||||
});
|
||||
return {
|
||||
configOptions: sessionSnapshot.configOptions,
|
||||
};
|
||||
} catch (err) {
|
||||
this.log(`setSessionConfigOption error: ${String(err)}`);
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
const session = this.sessionStore.getSession(params.sessionId);
|
||||
if (!session) {
|
||||
@@ -338,7 +633,6 @@ export class AcpGatewayAgent implements Agent {
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessionStore.cancelActiveRun(params.sessionId);
|
||||
try {
|
||||
await this.gateway.request("chat.abort", { sessionKey: session.sessionKey });
|
||||
@@ -401,22 +695,48 @@ export class AcpGatewayAgent implements Agent {
|
||||
|
||||
if (phase === "start") {
|
||||
if (!pending.toolCalls) {
|
||||
pending.toolCalls = new Set();
|
||||
pending.toolCalls = new Map();
|
||||
}
|
||||
if (pending.toolCalls.has(toolCallId)) {
|
||||
return;
|
||||
}
|
||||
pending.toolCalls.add(toolCallId);
|
||||
const args = data.args as Record<string, unknown> | undefined;
|
||||
const title = formatToolTitle(name, args);
|
||||
const kind = inferToolKind(name);
|
||||
const locations = extractToolCallLocations(args);
|
||||
pending.toolCalls.set(toolCallId, {
|
||||
title,
|
||||
kind,
|
||||
rawInput: args,
|
||||
locations,
|
||||
});
|
||||
await this.connection.sessionUpdate({
|
||||
sessionId: pending.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId,
|
||||
title: formatToolTitle(name, args),
|
||||
title,
|
||||
status: "in_progress",
|
||||
rawInput: args,
|
||||
kind: inferToolKind(name),
|
||||
kind,
|
||||
locations,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === "update") {
|
||||
const toolState = pending.toolCalls?.get(toolCallId);
|
||||
const partialResult = data.partialResult;
|
||||
await this.connection.sessionUpdate({
|
||||
sessionId: pending.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId,
|
||||
status: "in_progress",
|
||||
rawOutput: partialResult,
|
||||
content: extractToolCallContent(partialResult),
|
||||
locations: extractToolCallLocations(toolState?.locations, partialResult),
|
||||
},
|
||||
});
|
||||
return;
|
||||
@@ -424,6 +744,8 @@ export class AcpGatewayAgent implements Agent {
|
||||
|
||||
if (phase === "result") {
|
||||
const isError = Boolean(data.isError);
|
||||
const toolState = pending.toolCalls?.get(toolCallId);
|
||||
pending.toolCalls?.delete(toolCallId);
|
||||
await this.connection.sessionUpdate({
|
||||
sessionId: pending.sessionId,
|
||||
update: {
|
||||
@@ -431,6 +753,8 @@ export class AcpGatewayAgent implements Agent {
|
||||
toolCallId,
|
||||
status: isError ? "failed" : "completed",
|
||||
rawOutput: data.result,
|
||||
content: extractToolCallContent(data.result),
|
||||
locations: extractToolCallLocations(toolState?.locations, data.result),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -466,11 +790,11 @@ export class AcpGatewayAgent implements Agent {
|
||||
if (state === "final") {
|
||||
const rawStopReason = payload.stopReason as string | undefined;
|
||||
const stopReason: StopReason = rawStopReason === "max_tokens" ? "max_tokens" : "end_turn";
|
||||
this.finishPrompt(pending.sessionId, pending, stopReason);
|
||||
await this.finishPrompt(pending.sessionId, pending, stopReason);
|
||||
return;
|
||||
}
|
||||
if (state === "aborted") {
|
||||
this.finishPrompt(pending.sessionId, pending, "cancelled");
|
||||
await this.finishPrompt(pending.sessionId, pending, "cancelled");
|
||||
return;
|
||||
}
|
||||
if (state === "error") {
|
||||
@@ -478,7 +802,7 @@ export class AcpGatewayAgent implements Agent {
|
||||
// do not treat transient backend errors (timeouts, rate-limits) as deliberate
|
||||
// refusals. TODO: when ChatEventSchema gains a structured errorKind field
|
||||
// (e.g. "refusal" | "timeout" | "rate_limit"), use it to distinguish here.
|
||||
this.finishPrompt(pending.sessionId, pending, "end_turn");
|
||||
void this.finishPrompt(pending.sessionId, pending, "end_turn");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,9 +835,21 @@ export class AcpGatewayAgent implements Agent {
|
||||
});
|
||||
}
|
||||
|
||||
private finishPrompt(sessionId: string, pending: PendingPrompt, stopReason: StopReason): void {
|
||||
private async finishPrompt(
|
||||
sessionId: string,
|
||||
pending: PendingPrompt,
|
||||
stopReason: StopReason,
|
||||
): Promise<void> {
|
||||
this.pendingPrompts.delete(sessionId);
|
||||
this.sessionStore.clearActiveRun(sessionId);
|
||||
const sessionSnapshot = await this.getSessionSnapshot(pending.sessionKey);
|
||||
try {
|
||||
await this.sendSessionSnapshotUpdate(sessionId, sessionSnapshot, {
|
||||
includeControls: false,
|
||||
});
|
||||
} catch (err) {
|
||||
this.log(`session snapshot update failed for ${sessionId}: ${String(err)}`);
|
||||
}
|
||||
pending.resolve({ stopReason });
|
||||
}
|
||||
|
||||
@@ -536,6 +872,183 @@ export class AcpGatewayAgent implements Agent {
|
||||
});
|
||||
}
|
||||
|
||||
private async getSessionSnapshot(
|
||||
sessionKey: string,
|
||||
overrides?: Partial<GatewaySessionPresentationRow>,
|
||||
): Promise<SessionSnapshot> {
|
||||
try {
|
||||
const row = await this.getGatewaySessionRow(sessionKey);
|
||||
return {
|
||||
...buildSessionPresentation({ row, overrides }),
|
||||
metadata: buildSessionMetadata({ row, sessionKey }),
|
||||
usage: buildSessionUsageSnapshot(row),
|
||||
};
|
||||
} catch (err) {
|
||||
this.log(`session presentation fallback for ${sessionKey}: ${String(err)}`);
|
||||
return {
|
||||
...buildSessionPresentation({ overrides }),
|
||||
metadata: buildSessionMetadata({ sessionKey }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async getGatewaySessionRow(
|
||||
sessionKey: string,
|
||||
): Promise<GatewaySessionPresentationRow | undefined> {
|
||||
const result = await this.gateway.request<SessionsListResult>("sessions.list", {
|
||||
limit: 200,
|
||||
search: sessionKey,
|
||||
includeDerivedTitles: true,
|
||||
});
|
||||
const session = result.sessions.find((entry) => entry.key === sessionKey);
|
||||
if (!session) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
displayName: session.displayName,
|
||||
label: session.label,
|
||||
derivedTitle: session.derivedTitle,
|
||||
updatedAt: session.updatedAt,
|
||||
thinkingLevel: session.thinkingLevel,
|
||||
modelProvider: session.modelProvider,
|
||||
model: session.model,
|
||||
verboseLevel: session.verboseLevel,
|
||||
reasoningLevel: session.reasoningLevel,
|
||||
responseUsage: session.responseUsage,
|
||||
elevatedLevel: session.elevatedLevel,
|
||||
totalTokens: session.totalTokens,
|
||||
totalTokensFresh: session.totalTokensFresh,
|
||||
contextTokens: session.contextTokens,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveSessionConfigPatch(
|
||||
configId: string,
|
||||
value: string,
|
||||
): {
|
||||
overrides: Partial<GatewaySessionPresentationRow>;
|
||||
patch: Record<string, string>;
|
||||
} {
|
||||
switch (configId) {
|
||||
case ACP_THOUGHT_LEVEL_CONFIG_ID:
|
||||
return {
|
||||
patch: { thinkingLevel: value },
|
||||
overrides: { thinkingLevel: value },
|
||||
};
|
||||
case ACP_VERBOSE_LEVEL_CONFIG_ID:
|
||||
return {
|
||||
patch: { verboseLevel: value },
|
||||
overrides: { verboseLevel: value },
|
||||
};
|
||||
case ACP_REASONING_LEVEL_CONFIG_ID:
|
||||
return {
|
||||
patch: { reasoningLevel: value },
|
||||
overrides: { reasoningLevel: value },
|
||||
};
|
||||
case ACP_RESPONSE_USAGE_CONFIG_ID:
|
||||
return {
|
||||
patch: { responseUsage: value },
|
||||
overrides: { responseUsage: value as GatewaySessionPresentationRow["responseUsage"] },
|
||||
};
|
||||
case ACP_ELEVATED_LEVEL_CONFIG_ID:
|
||||
return {
|
||||
patch: { elevatedLevel: value },
|
||||
overrides: { elevatedLevel: value },
|
||||
};
|
||||
default:
|
||||
throw new Error(`ACP bridge mode does not support session config option "${configId}".`);
|
||||
}
|
||||
}
|
||||
|
||||
private async getSessionTranscript(sessionKey: string): Promise<GatewayTranscriptMessage[]> {
|
||||
const result = await this.gateway.request<{ messages?: unknown[] }>("sessions.get", {
|
||||
key: sessionKey,
|
||||
limit: ACP_LOAD_SESSION_REPLAY_LIMIT,
|
||||
});
|
||||
if (!Array.isArray(result.messages)) {
|
||||
return [];
|
||||
}
|
||||
return result.messages as GatewayTranscriptMessage[];
|
||||
}
|
||||
|
||||
private async replaySessionTranscript(
|
||||
sessionId: string,
|
||||
transcript: ReadonlyArray<GatewayTranscriptMessage>,
|
||||
): Promise<void> {
|
||||
for (const message of transcript) {
|
||||
const role = typeof message.role === "string" ? message.role : "";
|
||||
if (role !== "user" && role !== "assistant") {
|
||||
continue;
|
||||
}
|
||||
const text = extractReplayText(message.content);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
await this.connection.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: role === "user" ? "user_message_chunk" : "agent_message_chunk",
|
||||
content: { type: "text", text },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async sendSessionSnapshotUpdate(
|
||||
sessionId: string,
|
||||
sessionSnapshot: SessionSnapshot,
|
||||
options: { includeControls: boolean },
|
||||
): Promise<void> {
|
||||
if (options.includeControls) {
|
||||
await this.connection.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "current_mode_update",
|
||||
currentModeId: sessionSnapshot.modes.currentModeId,
|
||||
},
|
||||
});
|
||||
await this.connection.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: sessionSnapshot.configOptions,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (sessionSnapshot.metadata) {
|
||||
await this.connection.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "session_info_update",
|
||||
...sessionSnapshot.metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (sessionSnapshot.usage) {
|
||||
await this.connection.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used: sessionSnapshot.usage.used,
|
||||
size: sessionSnapshot.usage.size,
|
||||
_meta: {
|
||||
source: "gateway-session-store",
|
||||
approximate: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private assertSupportedSessionSetup(mcpServers: ReadonlyArray<unknown>): void {
|
||||
if (mcpServers.length === 0) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
"ACP bridge mode does not support per-session MCP servers. Configure MCP on the OpenClaw gateway or agent instead.",
|
||||
);
|
||||
}
|
||||
|
||||
private enforceSessionCreateRateLimit(method: "newSession" | "loadSession"): void {
|
||||
const budget = this.sessionCreateRateLimiter.consume();
|
||||
if (budget.allowed) {
|
||||
|
||||
@@ -56,6 +56,7 @@ export type SpawnAcpParams = {
|
||||
task: string;
|
||||
label?: string;
|
||||
agentId?: string;
|
||||
resumeSessionId?: string;
|
||||
cwd?: string;
|
||||
mode?: SpawnAcpMode;
|
||||
thread?: boolean;
|
||||
@@ -426,6 +427,7 @@ export async function spawnAcpDirect(
|
||||
sessionKey,
|
||||
agent: targetAgentId,
|
||||
mode: runtimeMode,
|
||||
resumeSessionId: params.resumeSessionId,
|
||||
cwd: params.cwd,
|
||||
backendId: cfg.acp?.backend,
|
||||
});
|
||||
|
||||
@@ -190,6 +190,58 @@ describe("markAuthProfileFailure", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("resets error count when previous cooldown has expired to prevent escalation", async () => {
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));
|
||||
try {
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
const now = Date.now();
|
||||
// Simulate state left on disk after 3 rapid failures within a 1-min cooldown
|
||||
// window. The cooldown has since expired, but clearExpiredCooldowns() only
|
||||
// ran in-memory and never persisted — so disk still carries errorCount: 3.
|
||||
fs.writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:default": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-default",
|
||||
},
|
||||
},
|
||||
usageStats: {
|
||||
"anthropic:default": {
|
||||
errorCount: 3,
|
||||
failureCounts: { rate_limit: 3 },
|
||||
lastFailureAt: now - 120_000, // 2 minutes ago
|
||||
cooldownUntil: now - 60_000, // expired 1 minute ago
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const store = ensureAuthProfileStore(agentDir);
|
||||
await markAuthProfileFailure({
|
||||
store,
|
||||
profileId: "anthropic:default",
|
||||
reason: "rate_limit",
|
||||
agentDir,
|
||||
});
|
||||
|
||||
const stats = store.usageStats?.["anthropic:default"];
|
||||
// Error count should reset to 1 (not escalate to 4) because the
|
||||
// previous cooldown expired. Cooldown should be ~1 min, not ~60 min.
|
||||
expect(stats?.errorCount).toBe(1);
|
||||
expect(stats?.failureCounts?.rate_limit).toBe(1);
|
||||
const cooldownMs = (stats?.cooldownUntil ?? 0) - now;
|
||||
// calculateAuthProfileCooldownMs(1) = 60_000 (1 minute)
|
||||
expect(cooldownMs).toBeLessThan(120_000);
|
||||
expect(cooldownMs).toBeGreaterThan(0);
|
||||
} finally {
|
||||
fs.rmSync(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not persist cooldown windows for OpenRouter profiles", async () => {
|
||||
await withAuthProfileStore(async ({ agentDir, store }) => {
|
||||
await markAuthProfileFailure({
|
||||
|
||||
38
src/agents/auth-profiles/state-observation.test.ts
Normal file
38
src/agents/auth-profiles/state-observation.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resetLogger, setLoggerOverride } from "../../logging/logger.js";
|
||||
import { logAuthProfileFailureStateChange } from "./state-observation.js";
|
||||
|
||||
afterEach(() => {
|
||||
setLoggerOverride(null);
|
||||
resetLogger();
|
||||
});
|
||||
|
||||
describe("logAuthProfileFailureStateChange", () => {
|
||||
it("sanitizes consoleMessage fields before logging", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
setLoggerOverride({ level: "silent", consoleLevel: "warn" });
|
||||
|
||||
logAuthProfileFailureStateChange({
|
||||
runId: "run-1\nforged\tentry\rtest",
|
||||
profileId: "openai:profile-1",
|
||||
provider: "openai\u001b]8;;https://evil.test\u0007",
|
||||
reason: "overloaded",
|
||||
previous: undefined,
|
||||
next: {
|
||||
errorCount: 1,
|
||||
cooldownUntil: 1_700_000_060_000,
|
||||
failureCounts: { overloaded: 1 },
|
||||
},
|
||||
now: 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const consoleLine = warnSpy.mock.calls[0]?.[0];
|
||||
expect(typeof consoleLine).toBe("string");
|
||||
expect(consoleLine).toContain("runId=run-1 forged entry test");
|
||||
expect(consoleLine).toContain("provider=openai]8;;https://evil.test");
|
||||
expect(consoleLine).not.toContain("\n");
|
||||
expect(consoleLine).not.toContain("\r");
|
||||
expect(consoleLine).not.toContain("\t");
|
||||
expect(consoleLine).not.toContain("\u001b");
|
||||
});
|
||||
});
|
||||
59
src/agents/auth-profiles/state-observation.ts
Normal file
59
src/agents/auth-profiles/state-observation.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { redactIdentifier } from "../../logging/redact-identifier.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { sanitizeForConsole } from "../pi-embedded-error-observation.js";
|
||||
import type { AuthProfileFailureReason, ProfileUsageStats } from "./types.js";
|
||||
|
||||
const observationLog = createSubsystemLogger("agent/embedded");
|
||||
|
||||
export function logAuthProfileFailureStateChange(params: {
|
||||
runId?: string;
|
||||
profileId: string;
|
||||
provider: string;
|
||||
reason: AuthProfileFailureReason;
|
||||
previous: ProfileUsageStats | undefined;
|
||||
next: ProfileUsageStats;
|
||||
now: number;
|
||||
}): void {
|
||||
const windowType =
|
||||
params.reason === "billing" || params.reason === "auth_permanent" ? "disabled" : "cooldown";
|
||||
const previousCooldownUntil = params.previous?.cooldownUntil;
|
||||
const previousDisabledUntil = params.previous?.disabledUntil;
|
||||
// Active cooldown/disable windows are intentionally immutable; log whether this
|
||||
// update reused the existing window instead of extending it.
|
||||
const windowReused =
|
||||
windowType === "disabled"
|
||||
? typeof previousDisabledUntil === "number" &&
|
||||
Number.isFinite(previousDisabledUntil) &&
|
||||
previousDisabledUntil > params.now &&
|
||||
previousDisabledUntil === params.next.disabledUntil
|
||||
: typeof previousCooldownUntil === "number" &&
|
||||
Number.isFinite(previousCooldownUntil) &&
|
||||
previousCooldownUntil > params.now &&
|
||||
previousCooldownUntil === params.next.cooldownUntil;
|
||||
const safeProfileId = redactIdentifier(params.profileId, { len: 12 });
|
||||
const safeRunId = sanitizeForConsole(params.runId) ?? "-";
|
||||
const safeProvider = sanitizeForConsole(params.provider) ?? "-";
|
||||
|
||||
observationLog.warn("auth profile failure state updated", {
|
||||
event: "auth_profile_failure_state_updated",
|
||||
tags: ["error_handling", "auth_profiles", windowType],
|
||||
runId: params.runId,
|
||||
profileId: safeProfileId,
|
||||
provider: params.provider,
|
||||
reason: params.reason,
|
||||
windowType,
|
||||
windowReused,
|
||||
previousErrorCount: params.previous?.errorCount,
|
||||
errorCount: params.next.errorCount,
|
||||
previousCooldownUntil,
|
||||
cooldownUntil: params.next.cooldownUntil,
|
||||
previousDisabledUntil,
|
||||
disabledUntil: params.next.disabledUntil,
|
||||
previousDisabledReason: params.previous?.disabledReason,
|
||||
disabledReason: params.next.disabledReason,
|
||||
failureCounts: params.next.failureCounts,
|
||||
consoleMessage:
|
||||
`auth profile failure state updated: runId=${safeRunId} profile=${safeProfileId} provider=${safeProvider} ` +
|
||||
`reason=${params.reason} window=${windowType} reused=${String(windowReused)}`,
|
||||
});
|
||||
}
|
||||
@@ -608,6 +608,10 @@ describe("markAuthProfileFailure — active windows do not extend on retry", ()
|
||||
});
|
||||
}
|
||||
|
||||
// When a cooldown/disabled window expires, the error count resets to prevent
|
||||
// stale counters from escalating the next cooldown (the root cause of
|
||||
// infinite cooldown loops — see #40989). The next failure should compute
|
||||
// backoff from errorCount=1, not from the accumulated stale count.
|
||||
const expiredWindowCases = [
|
||||
{
|
||||
label: "cooldownUntil",
|
||||
@@ -617,7 +621,8 @@ describe("markAuthProfileFailure — active windows do not extend on retry", ()
|
||||
errorCount: 3,
|
||||
lastFailureAt: now - 60_000,
|
||||
}),
|
||||
expectedUntil: (now: number) => now + 60 * 60 * 1000,
|
||||
// errorCount resets → calculateAuthProfileCooldownMs(1) = 60_000
|
||||
expectedUntil: (now: number) => now + 60_000,
|
||||
readUntil: (stats: WindowStats | undefined) => stats?.cooldownUntil,
|
||||
},
|
||||
{
|
||||
@@ -630,7 +635,9 @@ describe("markAuthProfileFailure — active windows do not extend on retry", ()
|
||||
failureCounts: { billing: 2 },
|
||||
lastFailureAt: now - 60_000,
|
||||
}),
|
||||
expectedUntil: (now: number) => now + 20 * 60 * 60 * 1000,
|
||||
// errorCount resets, billing count resets to 1 →
|
||||
// calculateAuthProfileBillingDisableMsWithConfig(1, 5h, 24h) = 5h
|
||||
expectedUntil: (now: number) => now + 5 * 60 * 60 * 1000,
|
||||
readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil,
|
||||
},
|
||||
{
|
||||
@@ -643,7 +650,9 @@ describe("markAuthProfileFailure — active windows do not extend on retry", ()
|
||||
failureCounts: { auth_permanent: 2 },
|
||||
lastFailureAt: now - 60_000,
|
||||
}),
|
||||
expectedUntil: (now: number) => now + 20 * 60 * 60 * 1000,
|
||||
// errorCount resets, auth_permanent count resets to 1 →
|
||||
// calculateAuthProfileBillingDisableMsWithConfig(1, 5h, 24h) = 5h
|
||||
expectedUntil: (now: number) => now + 5 * 60 * 60 * 1000,
|
||||
readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { normalizeProviderId } from "../model-selection.js";
|
||||
import { logAuthProfileFailureStateChange } from "./state-observation.js";
|
||||
import { saveAuthProfileStore, updateAuthProfileStoreWithLock } from "./store.js";
|
||||
import type { AuthProfileFailureReason, AuthProfileStore, ProfileUsageStats } from "./types.js";
|
||||
|
||||
@@ -400,9 +401,19 @@ function computeNextProfileUsageStats(params: {
|
||||
params.existing.lastFailureAt > 0 &&
|
||||
params.now - params.existing.lastFailureAt > windowMs;
|
||||
|
||||
const baseErrorCount = windowExpired ? 0 : (params.existing.errorCount ?? 0);
|
||||
// If the previous cooldown has already expired, reset error counters so the
|
||||
// profile gets a fresh backoff window. clearExpiredCooldowns() does this
|
||||
// in-memory during profile ordering, but the on-disk state may still carry
|
||||
// the old counters when the lock-based updater reads a fresh store. Without
|
||||
// this check, stale error counts from an expired cooldown cause the next
|
||||
// failure to escalate to a much longer cooldown (e.g. 1 min → 25 min).
|
||||
const unusableUntil = resolveProfileUnusableUntil(params.existing);
|
||||
const previousCooldownExpired = typeof unusableUntil === "number" && params.now >= unusableUntil;
|
||||
|
||||
const shouldResetCounters = windowExpired || previousCooldownExpired;
|
||||
const baseErrorCount = shouldResetCounters ? 0 : (params.existing.errorCount ?? 0);
|
||||
const nextErrorCount = baseErrorCount + 1;
|
||||
const failureCounts = windowExpired ? {} : { ...params.existing.failureCounts };
|
||||
const failureCounts = shouldResetCounters ? {} : { ...params.existing.failureCounts };
|
||||
failureCounts[params.reason] = (failureCounts[params.reason] ?? 0) + 1;
|
||||
|
||||
const updatedStats: ProfileUsageStats = {
|
||||
@@ -452,12 +463,16 @@ export async function markAuthProfileFailure(params: {
|
||||
reason: AuthProfileFailureReason;
|
||||
cfg?: OpenClawConfig;
|
||||
agentDir?: string;
|
||||
runId?: string;
|
||||
}): Promise<void> {
|
||||
const { store, profileId, reason, agentDir, cfg } = params;
|
||||
const { store, profileId, reason, agentDir, cfg, runId } = params;
|
||||
const profile = store.profiles[profileId];
|
||||
if (!profile || isAuthCooldownBypassedForProvider(profile.provider)) {
|
||||
return;
|
||||
}
|
||||
let nextStats: ProfileUsageStats | undefined;
|
||||
let previousStats: ProfileUsageStats | undefined;
|
||||
let updateTime = 0;
|
||||
const updated = await updateAuthProfileStoreWithLock({
|
||||
agentDir,
|
||||
updater: (freshStore) => {
|
||||
@@ -472,19 +487,32 @@ export async function markAuthProfileFailure(params: {
|
||||
providerId: providerKey,
|
||||
});
|
||||
|
||||
updateUsageStatsEntry(freshStore, profileId, (existing) =>
|
||||
computeNextProfileUsageStats({
|
||||
existing: existing ?? {},
|
||||
now,
|
||||
reason,
|
||||
cfgResolved,
|
||||
}),
|
||||
);
|
||||
previousStats = freshStore.usageStats?.[profileId];
|
||||
updateTime = now;
|
||||
const computed = computeNextProfileUsageStats({
|
||||
existing: previousStats ?? {},
|
||||
now,
|
||||
reason,
|
||||
cfgResolved,
|
||||
});
|
||||
nextStats = computed;
|
||||
updateUsageStatsEntry(freshStore, profileId, () => computed);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
if (updated) {
|
||||
store.usageStats = updated.usageStats;
|
||||
if (nextStats) {
|
||||
logAuthProfileFailureStateChange({
|
||||
runId,
|
||||
profileId,
|
||||
provider: profile.provider,
|
||||
reason,
|
||||
previous: previousStats,
|
||||
next: nextStats,
|
||||
now: updateTime,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!store.profiles[profileId]) {
|
||||
@@ -498,15 +526,25 @@ export async function markAuthProfileFailure(params: {
|
||||
providerId: providerKey,
|
||||
});
|
||||
|
||||
updateUsageStatsEntry(store, profileId, (existing) =>
|
||||
computeNextProfileUsageStats({
|
||||
existing: existing ?? {},
|
||||
now,
|
||||
reason,
|
||||
cfgResolved,
|
||||
}),
|
||||
);
|
||||
previousStats = store.usageStats?.[profileId];
|
||||
const computed = computeNextProfileUsageStats({
|
||||
existing: previousStats ?? {},
|
||||
now,
|
||||
reason,
|
||||
cfgResolved,
|
||||
});
|
||||
nextStats = computed;
|
||||
updateUsageStatsEntry(store, profileId, () => computed);
|
||||
saveAuthProfileStore(store, agentDir);
|
||||
logAuthProfileFailureStateChange({
|
||||
runId,
|
||||
profileId,
|
||||
provider: store.profiles[profileId]?.provider ?? profile.provider,
|
||||
reason,
|
||||
previous: previousStats,
|
||||
next: nextStats,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -518,12 +556,14 @@ export async function markAuthProfileCooldown(params: {
|
||||
store: AuthProfileStore;
|
||||
profileId: string;
|
||||
agentDir?: string;
|
||||
runId?: string;
|
||||
}): Promise<void> {
|
||||
await markAuthProfileFailure({
|
||||
store: params.store,
|
||||
profileId: params.profileId,
|
||||
reason: "unknown",
|
||||
agentDir: params.agentDir,
|
||||
runId: params.runId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
61
src/agents/bash-tools.exec-approval-followup.ts
Normal file
61
src/agents/bash-tools.exec-approval-followup.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { callGatewayTool } from "./tools/gateway.js";
|
||||
|
||||
type ExecApprovalFollowupParams = {
|
||||
approvalId: string;
|
||||
sessionKey?: string;
|
||||
turnSourceChannel?: string;
|
||||
turnSourceTo?: string;
|
||||
turnSourceAccountId?: string;
|
||||
turnSourceThreadId?: string | number;
|
||||
resultText: string;
|
||||
};
|
||||
|
||||
export function buildExecApprovalFollowupPrompt(resultText: string): string {
|
||||
return [
|
||||
"An async command the user already approved has completed.",
|
||||
"Do not run the command again.",
|
||||
"",
|
||||
"Exact completion details:",
|
||||
resultText.trim(),
|
||||
"",
|
||||
"Reply to the user in a helpful way.",
|
||||
"If it succeeded, share the relevant output.",
|
||||
"If it failed, explain what went wrong.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function sendExecApprovalFollowup(
|
||||
params: ExecApprovalFollowupParams,
|
||||
): Promise<boolean> {
|
||||
const sessionKey = params.sessionKey?.trim();
|
||||
const resultText = params.resultText.trim();
|
||||
if (!sessionKey || !resultText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const channel = params.turnSourceChannel?.trim();
|
||||
const to = params.turnSourceTo?.trim();
|
||||
const threadId =
|
||||
params.turnSourceThreadId != null && params.turnSourceThreadId !== ""
|
||||
? String(params.turnSourceThreadId)
|
||||
: undefined;
|
||||
|
||||
await callGatewayTool(
|
||||
"agent",
|
||||
{ timeoutMs: 60_000 },
|
||||
{
|
||||
sessionKey,
|
||||
message: buildExecApprovalFollowupPrompt(resultText),
|
||||
deliver: true,
|
||||
bestEffortDeliver: true,
|
||||
channel: channel && to ? channel : undefined,
|
||||
to: channel && to ? to : undefined,
|
||||
accountId: channel && to ? params.turnSourceAccountId?.trim() || undefined : undefined,
|
||||
threadId: channel && to ? threadId : undefined,
|
||||
idempotencyKey: `exec-approval-followup:${params.approvalId}`,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js";
|
||||
import {
|
||||
hasConfiguredExecApprovalDmRoute,
|
||||
resolveExecApprovalInitiatingSurfaceState,
|
||||
} from "../infra/exec-approval-surface.js";
|
||||
import {
|
||||
addAllowlistEntry,
|
||||
type ExecAsk,
|
||||
@@ -13,6 +19,7 @@ import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
|
||||
import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js";
|
||||
import { logInfo } from "../logger.js";
|
||||
import { markBackgrounded, tail } from "./bash-process-registry.js";
|
||||
import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js";
|
||||
import {
|
||||
buildExecApprovalRequesterContext,
|
||||
buildExecApprovalTurnSourceContext,
|
||||
@@ -25,9 +32,9 @@ import {
|
||||
resolveExecHostApprovalContext,
|
||||
} from "./bash-tools.exec-host-shared.js";
|
||||
import {
|
||||
buildApprovalPendingMessage,
|
||||
DEFAULT_NOTIFY_TAIL_CHARS,
|
||||
createApprovalSlug,
|
||||
emitExecSystemEvent,
|
||||
normalizeNotifyOutput,
|
||||
runExecProcess,
|
||||
} from "./bash-tools.exec-runtime.js";
|
||||
@@ -141,8 +148,6 @@ export async function processGatewayAllowlist(
|
||||
const {
|
||||
approvalId,
|
||||
approvalSlug,
|
||||
contextKey,
|
||||
noticeSeconds,
|
||||
warningText,
|
||||
expiresAtMs: defaultExpiresAtMs,
|
||||
preResolvedDecision: defaultPreResolvedDecision,
|
||||
@@ -174,19 +179,37 @@ export async function processGatewayAllowlist(
|
||||
});
|
||||
expiresAtMs = registration.expiresAtMs;
|
||||
preResolvedDecision = registration.finalDecision;
|
||||
const initiatingSurface = resolveExecApprovalInitiatingSurfaceState({
|
||||
channel: params.turnSourceChannel,
|
||||
accountId: params.turnSourceAccountId,
|
||||
});
|
||||
const cfg = loadConfig();
|
||||
const sentApproverDms =
|
||||
(initiatingSurface.kind === "disabled" || initiatingSurface.kind === "unsupported") &&
|
||||
hasConfiguredExecApprovalDmRoute(cfg);
|
||||
const unavailableReason =
|
||||
preResolvedDecision === null
|
||||
? "no-approval-route"
|
||||
: initiatingSurface.kind === "disabled"
|
||||
? "initiating-platform-disabled"
|
||||
: initiatingSurface.kind === "unsupported"
|
||||
? "initiating-platform-unsupported"
|
||||
: null;
|
||||
|
||||
void (async () => {
|
||||
const decision = await resolveApprovalDecisionOrUndefined({
|
||||
approvalId,
|
||||
preResolvedDecision,
|
||||
onFailure: () =>
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
|
||||
{
|
||||
sessionKey: params.notifySessionKey,
|
||||
contextKey,
|
||||
},
|
||||
),
|
||||
void sendExecApprovalFollowup({
|
||||
approvalId,
|
||||
sessionKey: params.notifySessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
resultText: `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
|
||||
}),
|
||||
});
|
||||
if (decision === undefined) {
|
||||
return;
|
||||
@@ -230,13 +253,15 @@ export async function processGatewayAllowlist(
|
||||
}
|
||||
|
||||
if (deniedReason) {
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`,
|
||||
{
|
||||
sessionKey: params.notifySessionKey,
|
||||
contextKey,
|
||||
},
|
||||
);
|
||||
await sendExecApprovalFollowup({
|
||||
approvalId,
|
||||
sessionKey: params.notifySessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
resultText: `Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`,
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -262,32 +287,21 @@ export async function processGatewayAllowlist(
|
||||
timeoutSec: effectiveTimeout,
|
||||
});
|
||||
} catch {
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`,
|
||||
{
|
||||
sessionKey: params.notifySessionKey,
|
||||
contextKey,
|
||||
},
|
||||
);
|
||||
await sendExecApprovalFollowup({
|
||||
approvalId,
|
||||
sessionKey: params.notifySessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
resultText: `Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`,
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
markBackgrounded(run.session);
|
||||
|
||||
let runningTimer: NodeJS.Timeout | null = null;
|
||||
if (params.approvalRunningNoticeMs > 0) {
|
||||
runningTimer = setTimeout(() => {
|
||||
emitExecSystemEvent(
|
||||
`Exec running (gateway id=${approvalId}, session=${run?.session.id}, >${noticeSeconds}s): ${params.command}`,
|
||||
{ sessionKey: params.notifySessionKey, contextKey },
|
||||
);
|
||||
}, params.approvalRunningNoticeMs);
|
||||
}
|
||||
|
||||
const outcome = await run.promise;
|
||||
if (runningTimer) {
|
||||
clearTimeout(runningTimer);
|
||||
}
|
||||
const output = normalizeNotifyOutput(
|
||||
tail(outcome.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS),
|
||||
);
|
||||
@@ -295,7 +309,15 @@ export async function processGatewayAllowlist(
|
||||
const summary = output
|
||||
? `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})\n${output}`
|
||||
: `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})`;
|
||||
emitExecSystemEvent(summary, { sessionKey: params.notifySessionKey, contextKey });
|
||||
await sendExecApprovalFollowup({
|
||||
approvalId,
|
||||
sessionKey: params.notifySessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
resultText: summary,
|
||||
}).catch(() => {});
|
||||
})();
|
||||
|
||||
return {
|
||||
@@ -304,19 +326,45 @@ export async function processGatewayAllowlist(
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`${warningText}Approval required (id ${approvalSlug}). ` +
|
||||
"Approve to run; updates will arrive after completion.",
|
||||
unavailableReason !== null
|
||||
? (buildExecApprovalUnavailableReplyPayload({
|
||||
warningText,
|
||||
reason: unavailableReason,
|
||||
channelLabel: initiatingSurface.channelLabel,
|
||||
sentApproverDms,
|
||||
}).text ?? "")
|
||||
: buildApprovalPendingMessage({
|
||||
warningText,
|
||||
approvalSlug,
|
||||
approvalId,
|
||||
command: params.command,
|
||||
cwd: params.workdir,
|
||||
host: "gateway",
|
||||
}),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
status: "approval-pending",
|
||||
approvalId,
|
||||
approvalSlug,
|
||||
expiresAtMs,
|
||||
host: "gateway",
|
||||
command: params.command,
|
||||
cwd: params.workdir,
|
||||
},
|
||||
details:
|
||||
unavailableReason !== null
|
||||
? ({
|
||||
status: "approval-unavailable",
|
||||
reason: unavailableReason,
|
||||
channelLabel: initiatingSurface.channelLabel,
|
||||
sentApproverDms,
|
||||
host: "gateway",
|
||||
command: params.command,
|
||||
cwd: params.workdir,
|
||||
warningText,
|
||||
} satisfies ExecToolDetails)
|
||||
: ({
|
||||
status: "approval-pending",
|
||||
approvalId,
|
||||
approvalSlug,
|
||||
expiresAtMs,
|
||||
host: "gateway",
|
||||
command: params.command,
|
||||
cwd: params.workdir,
|
||||
warningText,
|
||||
} satisfies ExecToolDetails),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js";
|
||||
import {
|
||||
hasConfiguredExecApprovalDmRoute,
|
||||
resolveExecApprovalInitiatingSurfaceState,
|
||||
} from "../infra/exec-approval-surface.js";
|
||||
import {
|
||||
type ExecApprovalsFile,
|
||||
type ExecAsk,
|
||||
@@ -12,6 +18,7 @@ import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
|
||||
import { buildNodeShellCommand } from "../infra/node-shell.js";
|
||||
import { parsePreparedSystemRunPayload } from "../infra/system-run-approval-context.js";
|
||||
import { logInfo } from "../logger.js";
|
||||
import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js";
|
||||
import {
|
||||
buildExecApprovalRequesterContext,
|
||||
buildExecApprovalTurnSourceContext,
|
||||
@@ -23,7 +30,12 @@ import {
|
||||
resolveApprovalDecisionOrUndefined,
|
||||
resolveExecHostApprovalContext,
|
||||
} from "./bash-tools.exec-host-shared.js";
|
||||
import { createApprovalSlug, emitExecSystemEvent } from "./bash-tools.exec-runtime.js";
|
||||
import {
|
||||
buildApprovalPendingMessage,
|
||||
DEFAULT_NOTIFY_TAIL_CHARS,
|
||||
createApprovalSlug,
|
||||
normalizeNotifyOutput,
|
||||
} from "./bash-tools.exec-runtime.js";
|
||||
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
|
||||
import { callGatewayTool } from "./tools/gateway.js";
|
||||
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
|
||||
@@ -187,6 +199,7 @@ export async function executeNodeHostCommand(
|
||||
approvedByAsk: boolean,
|
||||
approvalDecision: "allow-once" | "allow-always" | null,
|
||||
runId?: string,
|
||||
suppressNotifyOnExit?: boolean,
|
||||
) =>
|
||||
({
|
||||
nodeId,
|
||||
@@ -202,6 +215,7 @@ export async function executeNodeHostCommand(
|
||||
approved: approvedByAsk,
|
||||
approvalDecision: approvalDecision ?? undefined,
|
||||
runId: runId ?? undefined,
|
||||
suppressNotifyOnExit: suppressNotifyOnExit === true ? true : undefined,
|
||||
},
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
}) satisfies Record<string, unknown>;
|
||||
@@ -210,8 +224,6 @@ export async function executeNodeHostCommand(
|
||||
const {
|
||||
approvalId,
|
||||
approvalSlug,
|
||||
contextKey,
|
||||
noticeSeconds,
|
||||
warningText,
|
||||
expiresAtMs: defaultExpiresAtMs,
|
||||
preResolvedDecision: defaultPreResolvedDecision,
|
||||
@@ -243,16 +255,37 @@ export async function executeNodeHostCommand(
|
||||
});
|
||||
expiresAtMs = registration.expiresAtMs;
|
||||
preResolvedDecision = registration.finalDecision;
|
||||
const initiatingSurface = resolveExecApprovalInitiatingSurfaceState({
|
||||
channel: params.turnSourceChannel,
|
||||
accountId: params.turnSourceAccountId,
|
||||
});
|
||||
const cfg = loadConfig();
|
||||
const sentApproverDms =
|
||||
(initiatingSurface.kind === "disabled" || initiatingSurface.kind === "unsupported") &&
|
||||
hasConfiguredExecApprovalDmRoute(cfg);
|
||||
const unavailableReason =
|
||||
preResolvedDecision === null
|
||||
? "no-approval-route"
|
||||
: initiatingSurface.kind === "disabled"
|
||||
? "initiating-platform-disabled"
|
||||
: initiatingSurface.kind === "unsupported"
|
||||
? "initiating-platform-unsupported"
|
||||
: null;
|
||||
|
||||
void (async () => {
|
||||
const decision = await resolveApprovalDecisionOrUndefined({
|
||||
approvalId,
|
||||
preResolvedDecision,
|
||||
onFailure: () =>
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
|
||||
{ sessionKey: params.notifySessionKey, contextKey },
|
||||
),
|
||||
void sendExecApprovalFollowup({
|
||||
approvalId,
|
||||
sessionKey: params.notifySessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
resultText: `Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`,
|
||||
}),
|
||||
});
|
||||
if (decision === undefined) {
|
||||
return;
|
||||
@@ -278,44 +311,67 @@ export async function executeNodeHostCommand(
|
||||
}
|
||||
|
||||
if (deniedReason) {
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`,
|
||||
{
|
||||
sessionKey: params.notifySessionKey,
|
||||
contextKey,
|
||||
},
|
||||
);
|
||||
await sendExecApprovalFollowup({
|
||||
approvalId,
|
||||
sessionKey: params.notifySessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
resultText: `Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`,
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
let runningTimer: NodeJS.Timeout | null = null;
|
||||
if (params.approvalRunningNoticeMs > 0) {
|
||||
runningTimer = setTimeout(() => {
|
||||
emitExecSystemEvent(
|
||||
`Exec running (node=${nodeId} id=${approvalId}, >${noticeSeconds}s): ${params.command}`,
|
||||
{ sessionKey: params.notifySessionKey, contextKey },
|
||||
);
|
||||
}, params.approvalRunningNoticeMs);
|
||||
}
|
||||
|
||||
try {
|
||||
await callGatewayTool(
|
||||
const raw = await callGatewayTool<{
|
||||
payload?: {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string | null;
|
||||
exitCode?: number | null;
|
||||
timedOut?: boolean;
|
||||
};
|
||||
}>(
|
||||
"node.invoke",
|
||||
{ timeoutMs: invokeTimeoutMs },
|
||||
buildInvokeParams(approvedByAsk, approvalDecision, approvalId),
|
||||
buildInvokeParams(approvedByAsk, approvalDecision, approvalId, true),
|
||||
);
|
||||
const payload =
|
||||
raw?.payload && typeof raw.payload === "object"
|
||||
? (raw.payload as {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string | null;
|
||||
exitCode?: number | null;
|
||||
timedOut?: boolean;
|
||||
})
|
||||
: {};
|
||||
const combined = [payload.stdout, payload.stderr, payload.error].filter(Boolean).join("\n");
|
||||
const output = normalizeNotifyOutput(combined.slice(-DEFAULT_NOTIFY_TAIL_CHARS));
|
||||
const exitLabel = payload.timedOut ? "timeout" : `code ${payload.exitCode ?? "?"}`;
|
||||
const summary = output
|
||||
? `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})\n${output}`
|
||||
: `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})`;
|
||||
await sendExecApprovalFollowup({
|
||||
approvalId,
|
||||
sessionKey: params.notifySessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
resultText: summary,
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`,
|
||||
{
|
||||
sessionKey: params.notifySessionKey,
|
||||
contextKey,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (runningTimer) {
|
||||
clearTimeout(runningTimer);
|
||||
}
|
||||
await sendExecApprovalFollowup({
|
||||
approvalId,
|
||||
sessionKey: params.notifySessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
resultText: `Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`,
|
||||
}).catch(() => {});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -324,20 +380,48 @@ export async function executeNodeHostCommand(
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`${warningText}Approval required (id ${approvalSlug}). ` +
|
||||
"Approve to run; updates will arrive after completion.",
|
||||
unavailableReason !== null
|
||||
? (buildExecApprovalUnavailableReplyPayload({
|
||||
warningText,
|
||||
reason: unavailableReason,
|
||||
channelLabel: initiatingSurface.channelLabel,
|
||||
sentApproverDms,
|
||||
}).text ?? "")
|
||||
: buildApprovalPendingMessage({
|
||||
warningText,
|
||||
approvalSlug,
|
||||
approvalId,
|
||||
command: prepared.cmdText,
|
||||
cwd: runCwd,
|
||||
host: "node",
|
||||
nodeId,
|
||||
}),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
status: "approval-pending",
|
||||
approvalId,
|
||||
approvalSlug,
|
||||
expiresAtMs,
|
||||
host: "node",
|
||||
command: params.command,
|
||||
cwd: params.workdir,
|
||||
nodeId,
|
||||
},
|
||||
details:
|
||||
unavailableReason !== null
|
||||
? ({
|
||||
status: "approval-unavailable",
|
||||
reason: unavailableReason,
|
||||
channelLabel: initiatingSurface.channelLabel,
|
||||
sentApproverDms,
|
||||
host: "node",
|
||||
command: params.command,
|
||||
cwd: params.workdir,
|
||||
nodeId,
|
||||
warningText,
|
||||
} satisfies ExecToolDetails)
|
||||
: ({
|
||||
status: "approval-pending",
|
||||
approvalId,
|
||||
approvalSlug,
|
||||
expiresAtMs,
|
||||
host: "node",
|
||||
command: params.command,
|
||||
cwd: params.workdir,
|
||||
nodeId,
|
||||
warningText,
|
||||
} satisfies ExecToolDetails),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +230,40 @@ export function createApprovalSlug(id: string) {
|
||||
return id.slice(0, APPROVAL_SLUG_LENGTH);
|
||||
}
|
||||
|
||||
export function buildApprovalPendingMessage(params: {
|
||||
warningText?: string;
|
||||
approvalSlug: string;
|
||||
approvalId: string;
|
||||
command: string;
|
||||
cwd: string;
|
||||
host: "gateway" | "node";
|
||||
nodeId?: string;
|
||||
}) {
|
||||
let fence = "```";
|
||||
while (params.command.includes(fence)) {
|
||||
fence += "`";
|
||||
}
|
||||
const commandBlock = `${fence}sh\n${params.command}\n${fence}`;
|
||||
const lines: string[] = [];
|
||||
const warningText = params.warningText?.trim();
|
||||
if (warningText) {
|
||||
lines.push(warningText, "");
|
||||
}
|
||||
lines.push(`Approval required (id ${params.approvalSlug}, full ${params.approvalId}).`);
|
||||
lines.push(`Host: ${params.host}`);
|
||||
if (params.nodeId) {
|
||||
lines.push(`Node: ${params.nodeId}`);
|
||||
}
|
||||
lines.push(`CWD: ${params.cwd}`);
|
||||
lines.push("Command:");
|
||||
lines.push(commandBlock);
|
||||
lines.push("Mode: foreground (interactive approvals available).");
|
||||
lines.push("Background mode requires pre-approved policy (allow-always or ask=off).");
|
||||
lines.push(`Reply with: /approve ${params.approvalSlug} allow-once|allow-always|deny`);
|
||||
lines.push("If the short code is ambiguous, use the full id in /approve.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function resolveApprovalRunningNoticeMs(value?: number) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return DEFAULT_APPROVAL_RUNNING_NOTICE_MS;
|
||||
|
||||
@@ -60,4 +60,19 @@ export type ExecToolDetails =
|
||||
command: string;
|
||||
cwd?: string;
|
||||
nodeId?: string;
|
||||
warningText?: string;
|
||||
}
|
||||
| {
|
||||
status: "approval-unavailable";
|
||||
reason:
|
||||
| "initiating-platform-disabled"
|
||||
| "initiating-platform-unsupported"
|
||||
| "no-approval-route";
|
||||
channelLabel?: string;
|
||||
sentApproverDms?: boolean;
|
||||
host: ExecHost;
|
||||
command: string;
|
||||
cwd?: string;
|
||||
nodeId?: string;
|
||||
warningText?: string;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearConfigCache } from "../config/config.js";
|
||||
import { buildSystemRunPreparePayload } from "../test-utils/system-run-prepare-payload.js";
|
||||
|
||||
vi.mock("./tools/gateway.js", () => ({
|
||||
@@ -63,6 +64,7 @@ describe("exec approvals", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
clearConfigCache();
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
@@ -77,6 +79,7 @@ describe("exec approvals", () => {
|
||||
|
||||
it("reuses approval id as the node runId", async () => {
|
||||
let invokeParams: unknown;
|
||||
let agentParams: unknown;
|
||||
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
|
||||
if (method === "exec.approval.request") {
|
||||
@@ -85,6 +88,10 @@ describe("exec approvals", () => {
|
||||
if (method === "exec.approval.waitDecision") {
|
||||
return { decision: "allow-once" };
|
||||
}
|
||||
if (method === "agent") {
|
||||
agentParams = params;
|
||||
return { status: "ok" };
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
const invoke = params as { command?: string };
|
||||
if (invoke.command === "system.run.prepare") {
|
||||
@@ -102,11 +109,24 @@ describe("exec approvals", () => {
|
||||
host: "node",
|
||||
ask: "always",
|
||||
approvalRunningNoticeMs: 0,
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
const result = await tool.execute("call1", { command: "ls -la" });
|
||||
expect(result.details.status).toBe("approval-pending");
|
||||
const approvalId = (result.details as { approvalId: string }).approvalId;
|
||||
const details = result.details as { approvalId: string; approvalSlug: string };
|
||||
const pendingText = result.content.find((part) => part.type === "text")?.text ?? "";
|
||||
expect(pendingText).toContain(
|
||||
`Reply with: /approve ${details.approvalSlug} allow-once|allow-always|deny`,
|
||||
);
|
||||
expect(pendingText).toContain(`full ${details.approvalId}`);
|
||||
expect(pendingText).toContain("Host: node");
|
||||
expect(pendingText).toContain("Node: node-1");
|
||||
expect(pendingText).toContain(`CWD: ${process.cwd()}`);
|
||||
expect(pendingText).toContain("Command:\n```sh\nls -la\n```");
|
||||
expect(pendingText).toContain("Mode: foreground (interactive approvals available).");
|
||||
expect(pendingText).toContain("Background mode requires pre-approved policy");
|
||||
const approvalId = details.approvalId;
|
||||
|
||||
await expect
|
||||
.poll(() => (invokeParams as { params?: { runId?: string } } | undefined)?.params?.runId, {
|
||||
@@ -114,6 +134,12 @@ describe("exec approvals", () => {
|
||||
interval: 20,
|
||||
})
|
||||
.toBe(approvalId);
|
||||
expect(
|
||||
(invokeParams as { params?: { suppressNotifyOnExit?: boolean } } | undefined)?.params,
|
||||
).toMatchObject({
|
||||
suppressNotifyOnExit: true,
|
||||
});
|
||||
await expect.poll(() => agentParams, { timeout: 2_000, interval: 20 }).toBeTruthy();
|
||||
});
|
||||
|
||||
it("skips approval when node allowlist is satisfied", async () => {
|
||||
@@ -287,11 +313,181 @@ describe("exec approvals", () => {
|
||||
|
||||
const result = await tool.execute("call4", { command: "echo ok", elevated: true });
|
||||
expect(result.details.status).toBe("approval-pending");
|
||||
const details = result.details as { approvalId: string; approvalSlug: string };
|
||||
const pendingText = result.content.find((part) => part.type === "text")?.text ?? "";
|
||||
expect(pendingText).toContain(
|
||||
`Reply with: /approve ${details.approvalSlug} allow-once|allow-always|deny`,
|
||||
);
|
||||
expect(pendingText).toContain(`full ${details.approvalId}`);
|
||||
expect(pendingText).toContain("Host: gateway");
|
||||
expect(pendingText).toContain(`CWD: ${process.cwd()}`);
|
||||
expect(pendingText).toContain("Command:\n```sh\necho ok\n```");
|
||||
await approvalSeen;
|
||||
expect(calls).toContain("exec.approval.request");
|
||||
expect(calls).toContain("exec.approval.waitDecision");
|
||||
});
|
||||
|
||||
it("starts a direct agent follow-up after approved gateway exec completes", async () => {
|
||||
const agentCalls: Array<Record<string, unknown>> = [];
|
||||
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
|
||||
if (method === "exec.approval.request") {
|
||||
return { status: "accepted", id: (params as { id?: string })?.id };
|
||||
}
|
||||
if (method === "exec.approval.waitDecision") {
|
||||
return { decision: "allow-once" };
|
||||
}
|
||||
if (method === "agent") {
|
||||
agentCalls.push(params as Record<string, unknown>);
|
||||
return { status: "ok" };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const tool = createExecTool({
|
||||
host: "gateway",
|
||||
ask: "always",
|
||||
approvalRunningNoticeMs: 0,
|
||||
sessionKey: "agent:main:main",
|
||||
elevated: { enabled: true, allowed: true, defaultLevel: "ask" },
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-gw-followup", {
|
||||
command: "echo ok",
|
||||
workdir: process.cwd(),
|
||||
gatewayUrl: undefined,
|
||||
gatewayToken: undefined,
|
||||
});
|
||||
|
||||
expect(result.details.status).toBe("approval-pending");
|
||||
await expect.poll(() => agentCalls.length, { timeout: 3_000, interval: 20 }).toBe(1);
|
||||
expect(agentCalls[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main:main",
|
||||
deliver: true,
|
||||
idempotencyKey: expect.stringContaining("exec-approval-followup:"),
|
||||
}),
|
||||
);
|
||||
expect(typeof agentCalls[0]?.message).toBe("string");
|
||||
expect(agentCalls[0]?.message).toContain(
|
||||
"An async command the user already approved has completed.",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a separate approval for each elevated command after allow-once", async () => {
|
||||
const requestCommands: string[] = [];
|
||||
const requestIds: string[] = [];
|
||||
const waitIds: string[] = [];
|
||||
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
|
||||
if (method === "exec.approval.request") {
|
||||
const request = params as { id?: string; command?: string };
|
||||
if (typeof request.command === "string") {
|
||||
requestCommands.push(request.command);
|
||||
}
|
||||
if (typeof request.id === "string") {
|
||||
requestIds.push(request.id);
|
||||
}
|
||||
return { status: "accepted", id: request.id };
|
||||
}
|
||||
if (method === "exec.approval.waitDecision") {
|
||||
const wait = params as { id?: string };
|
||||
if (typeof wait.id === "string") {
|
||||
waitIds.push(wait.id);
|
||||
}
|
||||
return { decision: "allow-once" };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const tool = createExecTool({
|
||||
ask: "on-miss",
|
||||
security: "allowlist",
|
||||
approvalRunningNoticeMs: 0,
|
||||
elevated: { enabled: true, allowed: true, defaultLevel: "ask" },
|
||||
});
|
||||
|
||||
const first = await tool.execute("call-seq-1", {
|
||||
command: "npm view diver --json",
|
||||
elevated: true,
|
||||
});
|
||||
const second = await tool.execute("call-seq-2", {
|
||||
command: "brew outdated",
|
||||
elevated: true,
|
||||
});
|
||||
|
||||
expect(first.details.status).toBe("approval-pending");
|
||||
expect(second.details.status).toBe("approval-pending");
|
||||
expect(requestCommands).toEqual(["npm view diver --json", "brew outdated"]);
|
||||
expect(requestIds).toHaveLength(2);
|
||||
expect(requestIds[0]).not.toBe(requestIds[1]);
|
||||
expect(waitIds).toEqual(requestIds);
|
||||
});
|
||||
|
||||
it("shows full chained gateway commands in approval-pending message", async () => {
|
||||
const calls: string[] = [];
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
|
||||
calls.push(method);
|
||||
if (method === "exec.approval.request") {
|
||||
return { status: "accepted", id: (params as { id?: string })?.id };
|
||||
}
|
||||
if (method === "exec.approval.waitDecision") {
|
||||
return { decision: "deny" };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const tool = createExecTool({
|
||||
host: "gateway",
|
||||
ask: "on-miss",
|
||||
security: "allowlist",
|
||||
approvalRunningNoticeMs: 0,
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-chain-gateway", {
|
||||
command: "npm view diver --json | jq .name && brew outdated",
|
||||
});
|
||||
|
||||
expect(result.details.status).toBe("approval-pending");
|
||||
const pendingText = result.content.find((part) => part.type === "text")?.text ?? "";
|
||||
expect(pendingText).toContain(
|
||||
"Command:\n```sh\nnpm view diver --json | jq .name && brew outdated\n```",
|
||||
);
|
||||
expect(calls).toContain("exec.approval.request");
|
||||
});
|
||||
|
||||
it("shows full chained node commands in approval-pending message", async () => {
|
||||
const calls: string[] = [];
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
|
||||
calls.push(method);
|
||||
if (method === "node.invoke") {
|
||||
const invoke = params as { command?: string };
|
||||
if (invoke.command === "system.run.prepare") {
|
||||
return buildPreparedSystemRunPayload(params);
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const tool = createExecTool({
|
||||
host: "node",
|
||||
ask: "always",
|
||||
security: "full",
|
||||
approvalRunningNoticeMs: 0,
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-chain-node", {
|
||||
command: "npm view diver --json | jq .name && brew outdated",
|
||||
});
|
||||
|
||||
expect(result.details.status).toBe("approval-pending");
|
||||
const pendingText = result.content.find((part) => part.type === "text")?.text ?? "";
|
||||
expect(pendingText).toContain(
|
||||
"Command:\n```sh\nnpm view diver --json | jq .name && brew outdated\n```",
|
||||
);
|
||||
expect(calls).toContain("exec.approval.request");
|
||||
});
|
||||
|
||||
it("waits for approval registration before returning approval-pending", async () => {
|
||||
const calls: string[] = [];
|
||||
let resolveRegistration: ((value: unknown) => void) | undefined;
|
||||
@@ -354,6 +550,111 @@ describe("exec approvals", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns an unavailable approval message instead of a local /approve prompt when discord exec approvals are disabled", async () => {
|
||||
const configPath = path.join(process.env.HOME ?? "", ".openclaw", "openclaw.json");
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
execApprovals: { enabled: false },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
|
||||
if (method === "exec.approval.request") {
|
||||
return { status: "accepted", id: "approval-id" };
|
||||
}
|
||||
if (method === "exec.approval.waitDecision") {
|
||||
return { decision: null };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const tool = createExecTool({
|
||||
host: "gateway",
|
||||
ask: "always",
|
||||
approvalRunningNoticeMs: 0,
|
||||
messageProvider: "discord",
|
||||
accountId: "default",
|
||||
currentChannelId: "1234567890",
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-unavailable", {
|
||||
command: "npm view diver name version description",
|
||||
});
|
||||
|
||||
expect(result.details.status).toBe("approval-unavailable");
|
||||
const text = result.content.find((part) => part.type === "text")?.text ?? "";
|
||||
expect(text).toContain("chat exec approvals are not enabled on Discord");
|
||||
expect(text).toContain("Web UI or terminal UI");
|
||||
expect(text).not.toContain("/approve");
|
||||
expect(text).not.toContain("npm view diver name version description");
|
||||
expect(text).not.toContain("Pending command:");
|
||||
expect(text).not.toContain("Host:");
|
||||
expect(text).not.toContain("CWD:");
|
||||
});
|
||||
|
||||
it("tells Telegram users that allowed approvers were DMed when Telegram approvals are disabled but Discord DM approvals are enabled", async () => {
|
||||
const configPath = path.join(process.env.HOME ?? "", ".openclaw", "openclaw.json");
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
enabled: true,
|
||||
execApprovals: { enabled: false },
|
||||
},
|
||||
discord: {
|
||||
enabled: true,
|
||||
execApprovals: { enabled: true, approvers: ["123"], target: "dm" },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
|
||||
if (method === "exec.approval.request") {
|
||||
return { status: "accepted", id: "approval-id" };
|
||||
}
|
||||
if (method === "exec.approval.waitDecision") {
|
||||
return { decision: null };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const tool = createExecTool({
|
||||
host: "gateway",
|
||||
ask: "always",
|
||||
approvalRunningNoticeMs: 0,
|
||||
messageProvider: "telegram",
|
||||
accountId: "default",
|
||||
currentChannelId: "-1003841603622",
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-tg-unavailable", {
|
||||
command: "npm view diver name version description",
|
||||
});
|
||||
|
||||
expect(result.details.status).toBe("approval-unavailable");
|
||||
const text = result.content.find((part) => part.type === "text")?.text ?? "";
|
||||
expect(text).toContain("Approval required. I sent the allowed approvers DMs.");
|
||||
expect(text).not.toContain("/approve");
|
||||
expect(text).not.toContain("npm view diver name version description");
|
||||
expect(text).not.toContain("Pending command:");
|
||||
expect(text).not.toContain("Host:");
|
||||
expect(text).not.toContain("CWD:");
|
||||
});
|
||||
|
||||
it("denies node obfuscated command when approval request times out", async () => {
|
||||
vi.mocked(detectCommandObfuscation).mockReturnValue({
|
||||
detected: true,
|
||||
|
||||
@@ -67,6 +67,7 @@ describe("failover-error", () => {
|
||||
expect(resolveFailoverReasonFromError({ statusCode: "429" })).toBe("rate_limit");
|
||||
expect(resolveFailoverReasonFromError({ status: 403 })).toBe("auth");
|
||||
expect(resolveFailoverReasonFromError({ status: 408 })).toBe("timeout");
|
||||
expect(resolveFailoverReasonFromError({ status: 499 })).toBe("timeout");
|
||||
expect(resolveFailoverReasonFromError({ status: 400 })).toBe("format");
|
||||
// Keep the status-only path behavior-preserving and conservative.
|
||||
expect(resolveFailoverReasonFromError({ status: 500 })).toBeNull();
|
||||
@@ -93,6 +94,12 @@ describe("failover-error", () => {
|
||||
message: ANTHROPIC_OVERLOADED_PAYLOAD,
|
||||
}),
|
||||
).toBe("overloaded");
|
||||
expect(
|
||||
resolveFailoverReasonFromError({
|
||||
status: 499,
|
||||
message: ANTHROPIC_OVERLOADED_PAYLOAD,
|
||||
}),
|
||||
).toBe("overloaded");
|
||||
expect(
|
||||
resolveFailoverReasonFromError({
|
||||
status: 429,
|
||||
|
||||
93
src/agents/model-fallback-observation.ts
Normal file
93
src/agents/model-fallback-observation.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { sanitizeForLog } from "../terminal/ansi.js";
|
||||
import type { FallbackAttempt, ModelCandidate } from "./model-fallback.types.js";
|
||||
import { buildTextObservationFields } from "./pi-embedded-error-observation.js";
|
||||
import type { FailoverReason } from "./pi-embedded-helpers.js";
|
||||
|
||||
const decisionLog = createSubsystemLogger("model-fallback").child("decision");
|
||||
|
||||
function buildErrorObservationFields(error?: string): {
|
||||
errorPreview?: string;
|
||||
errorHash?: string;
|
||||
errorFingerprint?: string;
|
||||
httpCode?: string;
|
||||
providerErrorType?: string;
|
||||
providerErrorMessagePreview?: string;
|
||||
requestIdHash?: string;
|
||||
} {
|
||||
const observed = buildTextObservationFields(error);
|
||||
return {
|
||||
errorPreview: observed.textPreview,
|
||||
errorHash: observed.textHash,
|
||||
errorFingerprint: observed.textFingerprint,
|
||||
httpCode: observed.httpCode,
|
||||
providerErrorType: observed.providerErrorType,
|
||||
providerErrorMessagePreview: observed.providerErrorMessagePreview,
|
||||
requestIdHash: observed.requestIdHash,
|
||||
};
|
||||
}
|
||||
|
||||
export function logModelFallbackDecision(params: {
|
||||
decision:
|
||||
| "skip_candidate"
|
||||
| "probe_cooldown_candidate"
|
||||
| "candidate_failed"
|
||||
| "candidate_succeeded";
|
||||
runId?: string;
|
||||
requestedProvider: string;
|
||||
requestedModel: string;
|
||||
candidate: ModelCandidate;
|
||||
attempt?: number;
|
||||
total?: number;
|
||||
reason?: FailoverReason | null;
|
||||
status?: number;
|
||||
code?: string;
|
||||
error?: string;
|
||||
nextCandidate?: ModelCandidate;
|
||||
isPrimary?: boolean;
|
||||
requestedModelMatched?: boolean;
|
||||
fallbackConfigured?: boolean;
|
||||
allowTransientCooldownProbe?: boolean;
|
||||
profileCount?: number;
|
||||
previousAttempts?: FallbackAttempt[];
|
||||
}): void {
|
||||
const nextText = params.nextCandidate
|
||||
? `${sanitizeForLog(params.nextCandidate.provider)}/${sanitizeForLog(params.nextCandidate.model)}`
|
||||
: "none";
|
||||
const reasonText = params.reason ?? "unknown";
|
||||
const observedError = buildErrorObservationFields(params.error);
|
||||
decisionLog.warn("model fallback decision", {
|
||||
event: "model_fallback_decision",
|
||||
tags: ["error_handling", "model_fallback", params.decision],
|
||||
runId: params.runId,
|
||||
decision: params.decision,
|
||||
requestedProvider: params.requestedProvider,
|
||||
requestedModel: params.requestedModel,
|
||||
candidateProvider: params.candidate.provider,
|
||||
candidateModel: params.candidate.model,
|
||||
attempt: params.attempt,
|
||||
total: params.total,
|
||||
reason: params.reason,
|
||||
status: params.status,
|
||||
code: params.code,
|
||||
...observedError,
|
||||
nextCandidateProvider: params.nextCandidate?.provider,
|
||||
nextCandidateModel: params.nextCandidate?.model,
|
||||
isPrimary: params.isPrimary,
|
||||
requestedModelMatched: params.requestedModelMatched,
|
||||
fallbackConfigured: params.fallbackConfigured,
|
||||
allowTransientCooldownProbe: params.allowTransientCooldownProbe,
|
||||
profileCount: params.profileCount,
|
||||
previousAttempts: params.previousAttempts?.map((attempt) => ({
|
||||
provider: attempt.provider,
|
||||
model: attempt.model,
|
||||
reason: attempt.reason,
|
||||
status: attempt.status,
|
||||
code: attempt.code,
|
||||
...buildErrorObservationFields(attempt.error),
|
||||
})),
|
||||
consoleMessage:
|
||||
`model fallback decision: decision=${params.decision} requested=${sanitizeForLog(params.requestedProvider)}/${sanitizeForLog(params.requestedModel)} ` +
|
||||
`candidate=${sanitizeForLog(params.candidate.provider)}/${sanitizeForLog(params.candidate.model)} reason=${reasonText} next=${nextText}`,
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { registerLogTransport, resetLogger, setLoggerOverride } from "../logging/logger.js";
|
||||
import type { AuthProfileStore } from "./auth-profiles.js";
|
||||
import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js";
|
||||
|
||||
@@ -28,6 +31,7 @@ const mockedResolveProfilesUnavailableReason = vi.mocked(resolveProfilesUnavaila
|
||||
const mockedResolveAuthProfileOrder = vi.mocked(resolveAuthProfileOrder);
|
||||
|
||||
const makeCfg = makeModelFallbackCfg;
|
||||
let unregisterLogTransport: (() => void) | undefined;
|
||||
|
||||
function expectFallbackUsed(
|
||||
result: { result: unknown; attempts: Array<{ reason?: string }> },
|
||||
@@ -149,6 +153,10 @@ describe("runWithModelFallback – probe logic", () => {
|
||||
|
||||
afterEach(() => {
|
||||
Date.now = realDateNow;
|
||||
unregisterLogTransport?.();
|
||||
unregisterLogTransport = undefined;
|
||||
setLoggerOverride(null);
|
||||
resetLogger();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -194,6 +202,99 @@ describe("runWithModelFallback – probe logic", () => {
|
||||
expectPrimaryProbeSuccess(result, run, "probed-ok");
|
||||
});
|
||||
|
||||
it("logs primary metadata on probe success and failure fallback decisions", async () => {
|
||||
const cfg = makeCfg();
|
||||
const records: Array<Record<string, unknown>> = [];
|
||||
mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 60 * 1000);
|
||||
setLoggerOverride({
|
||||
level: "trace",
|
||||
consoleLevel: "silent",
|
||||
file: path.join(os.tmpdir(), `openclaw-model-fallback-probe-${Date.now()}.log`),
|
||||
});
|
||||
unregisterLogTransport = registerLogTransport((record) => {
|
||||
records.push(record);
|
||||
});
|
||||
|
||||
const run = vi.fn().mockResolvedValue("probed-ok");
|
||||
|
||||
const result = await runPrimaryCandidate(cfg, run);
|
||||
|
||||
expectPrimaryProbeSuccess(result, run, "probed-ok");
|
||||
|
||||
_probeThrottleInternals.lastProbeAttempt.clear();
|
||||
|
||||
const fallbackCfg = makeCfg({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-4.1-mini",
|
||||
fallbacks: ["anthropic/claude-haiku-3-5", "google/gemini-2-flash"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Partial<OpenClawConfig>);
|
||||
mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 60 * 1000);
|
||||
const fallbackRun = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 }))
|
||||
.mockResolvedValueOnce("fallback-ok");
|
||||
|
||||
const fallbackResult = await runPrimaryCandidate(fallbackCfg, fallbackRun);
|
||||
|
||||
expect(fallbackResult.result).toBe("fallback-ok");
|
||||
expect(fallbackRun).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini", {
|
||||
allowTransientCooldownProbe: true,
|
||||
});
|
||||
expect(fallbackRun).toHaveBeenNthCalledWith(2, "anthropic", "claude-haiku-3-5");
|
||||
|
||||
const decisionPayloads = records
|
||||
.filter(
|
||||
(record) =>
|
||||
record["2"] === "model fallback decision" &&
|
||||
record["1"] &&
|
||||
typeof record["1"] === "object",
|
||||
)
|
||||
.map((record) => record["1"] as Record<string, unknown>);
|
||||
|
||||
expect(decisionPayloads).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
event: "model_fallback_decision",
|
||||
decision: "probe_cooldown_candidate",
|
||||
candidateProvider: "openai",
|
||||
candidateModel: "gpt-4.1-mini",
|
||||
allowTransientCooldownProbe: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
event: "model_fallback_decision",
|
||||
decision: "candidate_succeeded",
|
||||
candidateProvider: "openai",
|
||||
candidateModel: "gpt-4.1-mini",
|
||||
isPrimary: true,
|
||||
requestedModelMatched: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
event: "model_fallback_decision",
|
||||
decision: "candidate_failed",
|
||||
candidateProvider: "openai",
|
||||
candidateModel: "gpt-4.1-mini",
|
||||
isPrimary: true,
|
||||
requestedModelMatched: true,
|
||||
nextCandidateProvider: "anthropic",
|
||||
nextCandidateModel: "claude-haiku-3-5",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
event: "model_fallback_decision",
|
||||
decision: "candidate_succeeded",
|
||||
candidateProvider: "anthropic",
|
||||
candidateModel: "claude-haiku-3-5",
|
||||
isPrimary: false,
|
||||
requestedModelMatched: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("probes primary model when cooldown already expired", async () => {
|
||||
const cfg = makeCfg();
|
||||
// Cooldown expired 5 min ago
|
||||
@@ -251,6 +352,36 @@ describe("runWithModelFallback – probe logic", () => {
|
||||
expectPrimaryProbeSuccess(result, run, "probed-ok");
|
||||
});
|
||||
|
||||
it("prunes stale probe throttle entries before checking eligibility", () => {
|
||||
_probeThrottleInternals.lastProbeAttempt.set(
|
||||
"stale",
|
||||
NOW - _probeThrottleInternals.PROBE_STATE_TTL_MS - 1,
|
||||
);
|
||||
_probeThrottleInternals.lastProbeAttempt.set("fresh", NOW - 5_000);
|
||||
|
||||
expect(_probeThrottleInternals.lastProbeAttempt.has("stale")).toBe(true);
|
||||
|
||||
expect(_probeThrottleInternals.isProbeThrottleOpen(NOW, "fresh")).toBe(false);
|
||||
|
||||
expect(_probeThrottleInternals.lastProbeAttempt.has("stale")).toBe(false);
|
||||
expect(_probeThrottleInternals.lastProbeAttempt.has("fresh")).toBe(true);
|
||||
});
|
||||
|
||||
it("caps probe throttle state by evicting the oldest entries", () => {
|
||||
for (let i = 0; i < _probeThrottleInternals.MAX_PROBE_KEYS; i += 1) {
|
||||
_probeThrottleInternals.lastProbeAttempt.set(`key-${i}`, NOW - (i + 1));
|
||||
}
|
||||
|
||||
_probeThrottleInternals.markProbeAttempt(NOW, "freshest");
|
||||
|
||||
expect(_probeThrottleInternals.lastProbeAttempt.size).toBe(
|
||||
_probeThrottleInternals.MAX_PROBE_KEYS,
|
||||
);
|
||||
expect(_probeThrottleInternals.lastProbeAttempt.has("freshest")).toBe(true);
|
||||
expect(_probeThrottleInternals.lastProbeAttempt.has("key-255")).toBe(false);
|
||||
expect(_probeThrottleInternals.lastProbeAttempt.has("key-0")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles non-finite soonest safely (treats as probe-worthy)", async () => {
|
||||
const cfg = makeCfg();
|
||||
|
||||
@@ -346,7 +477,7 @@ describe("runWithModelFallback – probe logic", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("skips billing-cooldowned primary when no fallback candidates exist", async () => {
|
||||
it("probes billing-cooldowned primary when no fallback candidates exist", async () => {
|
||||
const cfg = makeCfg({
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -358,20 +489,28 @@ describe("runWithModelFallback – probe logic", () => {
|
||||
},
|
||||
} as Partial<OpenClawConfig>);
|
||||
|
||||
// Billing cooldown far from expiry — would normally be skipped
|
||||
// Single-provider setups need periodic probes even when the billing
|
||||
// cooldown is far from expiry, otherwise topping up credits never recovers
|
||||
// without a restart.
|
||||
const expiresIn30Min = NOW + 30 * 60 * 1000;
|
||||
mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn30Min);
|
||||
mockedResolveProfilesUnavailableReason.mockReturnValue("billing");
|
||||
|
||||
await expect(
|
||||
runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
fallbacksOverride: [],
|
||||
run: vi.fn().mockResolvedValue("billing-recovered"),
|
||||
}),
|
||||
).rejects.toThrow("All models failed");
|
||||
const run = vi.fn().mockResolvedValue("billing-recovered");
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
fallbacksOverride: [],
|
||||
run,
|
||||
});
|
||||
|
||||
expect(result.result).toBe("billing-recovered");
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini", {
|
||||
allowTransientCooldownProbe: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("probes billing-cooldowned primary with fallbacks when near cooldown expiry", async () => {
|
||||
|
||||
@@ -207,6 +207,7 @@ async function runEmbeddedFallback(params: {
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "mock-1",
|
||||
runId: params.runId,
|
||||
agentDir: params.agentDir,
|
||||
run: (provider, model, options) =>
|
||||
runEmbeddedPiAgent({
|
||||
|
||||
@@ -536,7 +536,9 @@ describe("runWithModelFallback", () => {
|
||||
});
|
||||
|
||||
expect(result.result).toBe("ok");
|
||||
const warning = warnSpy.mock.calls[0]?.[0] as string;
|
||||
const warning = warnSpy.mock.calls
|
||||
.map((call) => call[0] as string)
|
||||
.find((value) => value.includes('Model "openai/gpt-6spoof" not found'));
|
||||
expect(warning).toContain('Model "openai/gpt-6spoof" not found');
|
||||
expect(warning).not.toContain("\u001B");
|
||||
expect(warning).not.toContain("\n");
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
isFailoverError,
|
||||
isTimeoutError,
|
||||
} from "./failover-error.js";
|
||||
import { logModelFallbackDecision } from "./model-fallback-observation.js";
|
||||
import type { FallbackAttempt, ModelCandidate } from "./model-fallback.types.js";
|
||||
import {
|
||||
buildConfiguredAllowlistKeys,
|
||||
buildModelAliasIndex,
|
||||
@@ -32,11 +34,6 @@ import { isLikelyContextOverflowError } from "./pi-embedded-helpers.js";
|
||||
|
||||
const log = createSubsystemLogger("model-fallback");
|
||||
|
||||
type ModelCandidate = {
|
||||
provider: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export type ModelFallbackRunOptions = {
|
||||
allowTransientCooldownProbe?: boolean;
|
||||
};
|
||||
@@ -47,15 +44,6 @@ type ModelFallbackRunFn<T> = (
|
||||
options?: ModelFallbackRunOptions,
|
||||
) => Promise<T>;
|
||||
|
||||
type FallbackAttempt = {
|
||||
provider: string;
|
||||
model: string;
|
||||
error: string;
|
||||
reason?: FailoverReason;
|
||||
status?: number;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fallback abort check. Only treats explicit AbortError names as user aborts.
|
||||
* Message-based checks (e.g., "aborted") can mask timeouts and skip fallback.
|
||||
@@ -342,12 +330,51 @@ const lastProbeAttempt = new Map<string, number>();
|
||||
const MIN_PROBE_INTERVAL_MS = 30_000; // 30 seconds between probes per key
|
||||
const PROBE_MARGIN_MS = 2 * 60 * 1000;
|
||||
const PROBE_SCOPE_DELIMITER = "::";
|
||||
const PROBE_STATE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const MAX_PROBE_KEYS = 256;
|
||||
|
||||
function resolveProbeThrottleKey(provider: string, agentDir?: string): string {
|
||||
const scope = String(agentDir ?? "").trim();
|
||||
return scope ? `${scope}${PROBE_SCOPE_DELIMITER}${provider}` : provider;
|
||||
}
|
||||
|
||||
function pruneProbeState(now: number): void {
|
||||
for (const [key, ts] of lastProbeAttempt) {
|
||||
if (!Number.isFinite(ts) || ts <= 0 || now - ts > PROBE_STATE_TTL_MS) {
|
||||
lastProbeAttempt.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enforceProbeStateCap(): void {
|
||||
while (lastProbeAttempt.size > MAX_PROBE_KEYS) {
|
||||
let oldestKey: string | null = null;
|
||||
let oldestTs = Number.POSITIVE_INFINITY;
|
||||
for (const [key, ts] of lastProbeAttempt) {
|
||||
if (ts < oldestTs) {
|
||||
oldestKey = key;
|
||||
oldestTs = ts;
|
||||
}
|
||||
}
|
||||
if (!oldestKey) {
|
||||
break;
|
||||
}
|
||||
lastProbeAttempt.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function isProbeThrottleOpen(now: number, throttleKey: string): boolean {
|
||||
pruneProbeState(now);
|
||||
const lastProbe = lastProbeAttempt.get(throttleKey) ?? 0;
|
||||
return now - lastProbe >= MIN_PROBE_INTERVAL_MS;
|
||||
}
|
||||
|
||||
function markProbeAttempt(now: number, throttleKey: string): void {
|
||||
pruneProbeState(now);
|
||||
lastProbeAttempt.set(throttleKey, now);
|
||||
enforceProbeStateCap();
|
||||
}
|
||||
|
||||
function shouldProbePrimaryDuringCooldown(params: {
|
||||
isPrimary: boolean;
|
||||
hasFallbackCandidates: boolean;
|
||||
@@ -360,8 +387,7 @@ function shouldProbePrimaryDuringCooldown(params: {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastProbe = lastProbeAttempt.get(params.throttleKey) ?? 0;
|
||||
if (params.now - lastProbe < MIN_PROBE_INTERVAL_MS) {
|
||||
if (!isProbeThrottleOpen(params.now, params.throttleKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -379,7 +405,12 @@ export const _probeThrottleInternals = {
|
||||
lastProbeAttempt,
|
||||
MIN_PROBE_INTERVAL_MS,
|
||||
PROBE_MARGIN_MS,
|
||||
PROBE_STATE_TTL_MS,
|
||||
MAX_PROBE_KEYS,
|
||||
resolveProbeThrottleKey,
|
||||
isProbeThrottleOpen,
|
||||
pruneProbeState,
|
||||
markProbeAttempt,
|
||||
} as const;
|
||||
|
||||
type CooldownDecision =
|
||||
@@ -429,11 +460,15 @@ function resolveCooldownDecision(params: {
|
||||
}
|
||||
|
||||
// Billing is semi-persistent: the user may fix their balance, or a transient
|
||||
// 402 might have been misclassified. Probe the primary only when fallbacks
|
||||
// exist; otherwise repeated single-provider probes just churn the disabled
|
||||
// auth state without opening any recovery path.
|
||||
// 402 might have been misclassified. Probe single-provider setups on the
|
||||
// standard throttle so they can recover without a restart; when fallbacks
|
||||
// exist, only probe near cooldown expiry so the fallback chain stays preferred.
|
||||
if (inferredReason === "billing") {
|
||||
if (params.isPrimary && params.hasFallbackCandidates && shouldProbe) {
|
||||
const shouldProbeSingleProviderBilling =
|
||||
params.isPrimary &&
|
||||
!params.hasFallbackCandidates &&
|
||||
isProbeThrottleOpen(params.now, params.probeThrottleKey);
|
||||
if (params.isPrimary && (shouldProbe || shouldProbeSingleProviderBilling)) {
|
||||
return { type: "attempt", reason: inferredReason, markProbe: true };
|
||||
}
|
||||
return {
|
||||
@@ -468,6 +503,7 @@ export async function runWithModelFallback<T>(params: {
|
||||
cfg: OpenClawConfig | undefined;
|
||||
provider: string;
|
||||
model: string;
|
||||
runId?: string;
|
||||
agentDir?: string;
|
||||
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
|
||||
fallbacksOverride?: string[];
|
||||
@@ -490,7 +526,11 @@ export async function runWithModelFallback<T>(params: {
|
||||
|
||||
for (let i = 0; i < candidates.length; i += 1) {
|
||||
const candidate = candidates[i];
|
||||
const isPrimary = i === 0;
|
||||
const requestedModel =
|
||||
params.provider === candidate.provider && params.model === candidate.model;
|
||||
let runOptions: ModelFallbackRunOptions | undefined;
|
||||
let attemptedDuringCooldown = false;
|
||||
if (authStore) {
|
||||
const profileIds = resolveAuthProfileOrder({
|
||||
cfg: params.cfg,
|
||||
@@ -501,9 +541,6 @@ export async function runWithModelFallback<T>(params: {
|
||||
|
||||
if (profileIds.length > 0 && !isAnyProfileAvailable) {
|
||||
// All profiles for this provider are in cooldown.
|
||||
const isPrimary = i === 0;
|
||||
const requestedModel =
|
||||
params.provider === candidate.provider && params.model === candidate.model;
|
||||
const now = Date.now();
|
||||
const probeThrottleKey = resolveProbeThrottleKey(candidate.provider, params.agentDir);
|
||||
const decision = resolveCooldownDecision({
|
||||
@@ -524,11 +561,27 @@ export async function runWithModelFallback<T>(params: {
|
||||
error: decision.error,
|
||||
reason: decision.reason,
|
||||
});
|
||||
logModelFallbackDecision({
|
||||
decision: "skip_candidate",
|
||||
runId: params.runId,
|
||||
requestedProvider: params.provider,
|
||||
requestedModel: params.model,
|
||||
candidate,
|
||||
attempt: i + 1,
|
||||
total: candidates.length,
|
||||
reason: decision.reason,
|
||||
error: decision.error,
|
||||
nextCandidate: candidates[i + 1],
|
||||
isPrimary,
|
||||
requestedModelMatched: requestedModel,
|
||||
fallbackConfigured: hasFallbackCandidates,
|
||||
profileCount: profileIds.length,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (decision.markProbe) {
|
||||
lastProbeAttempt.set(probeThrottleKey, now);
|
||||
markProbeAttempt(now, probeThrottleKey);
|
||||
}
|
||||
if (
|
||||
decision.reason === "rate_limit" ||
|
||||
@@ -537,6 +590,23 @@ export async function runWithModelFallback<T>(params: {
|
||||
) {
|
||||
runOptions = { allowTransientCooldownProbe: true };
|
||||
}
|
||||
attemptedDuringCooldown = true;
|
||||
logModelFallbackDecision({
|
||||
decision: "probe_cooldown_candidate",
|
||||
runId: params.runId,
|
||||
requestedProvider: params.provider,
|
||||
requestedModel: params.model,
|
||||
candidate,
|
||||
attempt: i + 1,
|
||||
total: candidates.length,
|
||||
reason: decision.reason,
|
||||
nextCandidate: candidates[i + 1],
|
||||
isPrimary,
|
||||
requestedModelMatched: requestedModel,
|
||||
fallbackConfigured: hasFallbackCandidates,
|
||||
allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe,
|
||||
profileCount: profileIds.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,6 +617,21 @@ export async function runWithModelFallback<T>(params: {
|
||||
options: runOptions,
|
||||
});
|
||||
if ("success" in attemptRun) {
|
||||
if (i > 0 || attempts.length > 0 || attemptedDuringCooldown) {
|
||||
logModelFallbackDecision({
|
||||
decision: "candidate_succeeded",
|
||||
runId: params.runId,
|
||||
requestedProvider: params.provider,
|
||||
requestedModel: params.model,
|
||||
candidate,
|
||||
attempt: i + 1,
|
||||
total: candidates.length,
|
||||
previousAttempts: attempts,
|
||||
isPrimary,
|
||||
requestedModelMatched: requestedModel,
|
||||
fallbackConfigured: hasFallbackCandidates,
|
||||
});
|
||||
}
|
||||
const notFoundAttempt =
|
||||
i > 0 ? attempts.find((a) => a.reason === "model_not_found") : undefined;
|
||||
if (notFoundAttempt) {
|
||||
@@ -590,6 +675,23 @@ export async function runWithModelFallback<T>(params: {
|
||||
status: described.status,
|
||||
code: described.code,
|
||||
});
|
||||
logModelFallbackDecision({
|
||||
decision: "candidate_failed",
|
||||
runId: params.runId,
|
||||
requestedProvider: params.provider,
|
||||
requestedModel: params.model,
|
||||
candidate,
|
||||
attempt: i + 1,
|
||||
total: candidates.length,
|
||||
reason: described.reason,
|
||||
status: described.status,
|
||||
code: described.code,
|
||||
error: described.message,
|
||||
nextCandidate: candidates[i + 1],
|
||||
isPrimary,
|
||||
requestedModelMatched: requestedModel,
|
||||
fallbackConfigured: hasFallbackCandidates,
|
||||
});
|
||||
await params.onError?.({
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
|
||||
15
src/agents/model-fallback.types.ts
Normal file
15
src/agents/model-fallback.types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { FailoverReason } from "./pi-embedded-helpers.js";
|
||||
|
||||
export type ModelCandidate = {
|
||||
provider: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export type FallbackAttempt = {
|
||||
provider: string;
|
||||
model: string;
|
||||
error: string;
|
||||
reason?: FailoverReason;
|
||||
status?: number;
|
||||
code?: string;
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolvePluginTools } from "../plugins/tools.js";
|
||||
import { getActiveRuntimeWebToolsMetadata } from "../secrets/runtime.js";
|
||||
import type { GatewayMessageChannel } from "../utils/message-channel.js";
|
||||
import { resolveSessionAgentId } from "./agent-scope.js";
|
||||
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
|
||||
@@ -69,9 +70,20 @@ export function createOpenClawTools(
|
||||
senderIsOwner?: boolean;
|
||||
/** Ephemeral session UUID — regenerated on /new and /reset. */
|
||||
sessionId?: string;
|
||||
/**
|
||||
* Workspace directory to pass to spawned subagents for inheritance.
|
||||
* Defaults to workspaceDir. Use this to pass the actual agent workspace when the
|
||||
* session itself is running in a copied-workspace sandbox (`ro` or `none`) so
|
||||
* subagents inherit the real workspace path instead of the sandbox copy.
|
||||
*/
|
||||
spawnWorkspaceDir?: string;
|
||||
} & SpawnedToolContext,
|
||||
): AnyAgentTool[] {
|
||||
const workspaceDir = resolveWorkspaceRoot(options?.workspaceDir);
|
||||
const spawnWorkspaceDir = resolveWorkspaceRoot(
|
||||
options?.spawnWorkspaceDir ?? options?.workspaceDir,
|
||||
);
|
||||
const runtimeWebTools = getActiveRuntimeWebToolsMetadata();
|
||||
const imageTool = options?.agentDir?.trim()
|
||||
? createImageTool({
|
||||
config: options?.config,
|
||||
@@ -100,10 +112,12 @@ export function createOpenClawTools(
|
||||
const webSearchTool = createWebSearchTool({
|
||||
config: options?.config,
|
||||
sandboxed: options?.sandboxed,
|
||||
runtimeWebSearch: runtimeWebTools?.search,
|
||||
});
|
||||
const webFetchTool = createWebFetchTool({
|
||||
config: options?.config,
|
||||
sandboxed: options?.sandboxed,
|
||||
runtimeFirecrawl: runtimeWebTools?.fetch.firecrawl,
|
||||
});
|
||||
const messageTool = options?.disableMessageTool
|
||||
? null
|
||||
@@ -178,7 +192,7 @@ export function createOpenClawTools(
|
||||
agentGroupSpace: options?.agentGroupSpace,
|
||||
sandboxed: options?.sandboxed,
|
||||
requesterAgentIdOverride: options?.requesterAgentIdOverride,
|
||||
workspaceDir,
|
||||
workspaceDir: spawnWorkspaceDir,
|
||||
}),
|
||||
createSubagentsTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
|
||||
135
src/agents/openclaw-tools.web-runtime.test.ts
Normal file
135
src/agents/openclaw-tools.web-runtime.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
activateSecretsRuntimeSnapshot,
|
||||
clearSecretsRuntimeSnapshot,
|
||||
prepareSecretsRuntimeSnapshot,
|
||||
} from "../secrets/runtime.js";
|
||||
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
|
||||
import { createOpenClawTools } from "./openclaw-tools.js";
|
||||
|
||||
vi.mock("../plugins/tools.js", () => ({
|
||||
resolvePluginTools: () => [],
|
||||
}));
|
||||
|
||||
function asConfig(value: unknown): OpenClawConfig {
|
||||
return value as OpenClawConfig;
|
||||
}
|
||||
|
||||
function findTool(name: string, config: OpenClawConfig) {
|
||||
const allTools = createOpenClawTools({ config, sandboxed: true });
|
||||
const tool = allTools.find((candidate) => candidate.name === name);
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) {
|
||||
throw new Error(`missing ${name} tool`);
|
||||
}
|
||||
return tool;
|
||||
}
|
||||
|
||||
function makeHeaders(map: Record<string, string>): { get: (key: string) => string | null } {
|
||||
return {
|
||||
get: (key) => map[key.toLowerCase()] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareAndActivate(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv }) {
|
||||
const snapshot = await prepareSecretsRuntimeSnapshot({
|
||||
config: params.config,
|
||||
env: params.env,
|
||||
agentDirs: ["/tmp/openclaw-agent-main"],
|
||||
loadAuthStore: () => ({ version: 1, profiles: {} }),
|
||||
});
|
||||
activateSecretsRuntimeSnapshot(snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
describe("openclaw tools runtime web metadata wiring", () => {
|
||||
const priorFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = priorFetch;
|
||||
clearSecretsRuntimeSnapshot();
|
||||
});
|
||||
|
||||
it("uses runtime-selected provider when higher-precedence provider ref is unresolved", async () => {
|
||||
const snapshot = await prepareAndActivate({
|
||||
config: asConfig({
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
apiKey: { source: "env", provider: "default", id: "MISSING_BRAVE_KEY_REF" },
|
||||
gemini: {
|
||||
apiKey: { source: "env", provider: "default", id: "GEMINI_WEB_KEY_REF" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
env: {
|
||||
GEMINI_WEB_KEY_REF: "gemini-runtime-key",
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.webTools.search.selectedProvider).toBe("gemini");
|
||||
|
||||
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: "runtime gemini ok" }] },
|
||||
groundingMetadata: { groundingChunks: [] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as Response),
|
||||
);
|
||||
global.fetch = withFetchPreconnect(mockFetch);
|
||||
|
||||
const webSearch = findTool("web_search", snapshot.config);
|
||||
const result = await webSearch.execute("call-runtime-search", { query: "runtime search" });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
expect(String(mockFetch.mock.calls[0]?.[0])).toContain("generativelanguage.googleapis.com");
|
||||
expect((result.details as { provider?: string }).provider).toBe("gemini");
|
||||
});
|
||||
|
||||
it("skips Firecrawl key resolution when runtime marks Firecrawl inactive", async () => {
|
||||
const snapshot = await prepareAndActivate({
|
||||
config: asConfig({
|
||||
tools: {
|
||||
web: {
|
||||
fetch: {
|
||||
firecrawl: {
|
||||
enabled: false,
|
||||
apiKey: { source: "env", provider: "default", id: "MISSING_FIRECRAWL_KEY_REF" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: makeHeaders({ "content-type": "text/html; charset=utf-8" }),
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
"<html><body><article><h1>Runtime Off</h1><p>Use direct fetch.</p></article></body></html>",
|
||||
),
|
||||
} as Response),
|
||||
);
|
||||
global.fetch = withFetchPreconnect(mockFetch);
|
||||
|
||||
const webFetch = findTool("web_fetch", snapshot.config);
|
||||
await webFetch.execute("call-runtime-fetch", { url: "https://example.com/runtime-off" });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://example.com/runtime-off");
|
||||
expect(String(mockFetch.mock.calls[0]?.[0])).not.toContain("api.firecrawl.dev");
|
||||
});
|
||||
});
|
||||
185
src/agents/pi-embedded-error-observation.test.ts
Normal file
185
src/agents/pi-embedded-error-observation.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import * as loggingConfigModule from "../logging/config.js";
|
||||
import {
|
||||
buildApiErrorObservationFields,
|
||||
buildTextObservationFields,
|
||||
sanitizeForConsole,
|
||||
} from "./pi-embedded-error-observation.js";
|
||||
|
||||
const OBSERVATION_BEARER_TOKEN = "sk-redact-test-token";
|
||||
const OBSERVATION_COOKIE_VALUE = "session-cookie-token";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("buildApiErrorObservationFields", () => {
|
||||
it("redacts request ids and exposes stable hashes instead of raw payloads", () => {
|
||||
const observed = buildApiErrorObservationFields(
|
||||
'{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_overload"}',
|
||||
);
|
||||
|
||||
expect(observed).toMatchObject({
|
||||
rawErrorPreview: expect.stringContaining('"request_id":"sha256:'),
|
||||
rawErrorHash: expect.stringMatching(/^sha256:/),
|
||||
rawErrorFingerprint: expect.stringMatching(/^sha256:/),
|
||||
providerErrorType: "overloaded_error",
|
||||
providerErrorMessagePreview: "Overloaded",
|
||||
requestIdHash: expect.stringMatching(/^sha256:/),
|
||||
});
|
||||
expect(observed.rawErrorPreview).not.toContain("req_overload");
|
||||
});
|
||||
|
||||
it("forces token redaction for observation previews", () => {
|
||||
const observed = buildApiErrorObservationFields(
|
||||
`Authorization: Bearer ${OBSERVATION_BEARER_TOKEN}`,
|
||||
);
|
||||
|
||||
expect(observed.rawErrorPreview).not.toContain(OBSERVATION_BEARER_TOKEN);
|
||||
expect(observed.rawErrorPreview).toContain(OBSERVATION_BEARER_TOKEN.slice(0, 6));
|
||||
expect(observed.rawErrorHash).toMatch(/^sha256:/);
|
||||
});
|
||||
|
||||
it("redacts observation-only header and cookie formats", () => {
|
||||
const observed = buildApiErrorObservationFields(
|
||||
`x-api-key: ${OBSERVATION_BEARER_TOKEN} Cookie: session=${OBSERVATION_COOKIE_VALUE}`,
|
||||
);
|
||||
|
||||
expect(observed.rawErrorPreview).not.toContain(OBSERVATION_COOKIE_VALUE);
|
||||
expect(observed.rawErrorPreview).toContain("x-api-key: ***");
|
||||
expect(observed.rawErrorPreview).toContain("Cookie: session=");
|
||||
});
|
||||
|
||||
it("does not let cookie redaction consume unrelated fields on the same line", () => {
|
||||
const observed = buildApiErrorObservationFields(
|
||||
`Cookie: session=${OBSERVATION_COOKIE_VALUE} status=503 request_id=req_cookie`,
|
||||
);
|
||||
|
||||
expect(observed.rawErrorPreview).toContain("Cookie: session=");
|
||||
expect(observed.rawErrorPreview).toContain("status=503");
|
||||
expect(observed.rawErrorPreview).toContain("request_id=sha256:");
|
||||
});
|
||||
|
||||
it("builds sanitized generic text observation fields", () => {
|
||||
const observed = buildTextObservationFields(
|
||||
'{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_prev"}',
|
||||
);
|
||||
|
||||
expect(observed).toMatchObject({
|
||||
textPreview: expect.stringContaining('"request_id":"sha256:'),
|
||||
textHash: expect.stringMatching(/^sha256:/),
|
||||
textFingerprint: expect.stringMatching(/^sha256:/),
|
||||
providerErrorType: "overloaded_error",
|
||||
providerErrorMessagePreview: "Overloaded",
|
||||
requestIdHash: expect.stringMatching(/^sha256:/),
|
||||
});
|
||||
expect(observed.textPreview).not.toContain("req_prev");
|
||||
});
|
||||
|
||||
it("redacts request ids in formatted plain-text errors", () => {
|
||||
const observed = buildApiErrorObservationFields(
|
||||
"LLM error overloaded_error: Overloaded (request_id: req_plaintext_123)",
|
||||
);
|
||||
|
||||
expect(observed).toMatchObject({
|
||||
rawErrorPreview: expect.stringContaining("request_id: sha256:"),
|
||||
rawErrorFingerprint: expect.stringMatching(/^sha256:/),
|
||||
requestIdHash: expect.stringMatching(/^sha256:/),
|
||||
});
|
||||
expect(observed.rawErrorPreview).not.toContain("req_plaintext_123");
|
||||
});
|
||||
|
||||
it("keeps fingerprints stable across request ids for equivalent errors", () => {
|
||||
const first = buildApiErrorObservationFields(
|
||||
'{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_001"}',
|
||||
);
|
||||
const second = buildApiErrorObservationFields(
|
||||
'{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_002"}',
|
||||
);
|
||||
|
||||
expect(first.rawErrorFingerprint).toBe(second.rawErrorFingerprint);
|
||||
expect(first.rawErrorHash).not.toBe(second.rawErrorHash);
|
||||
});
|
||||
|
||||
it("truncates oversized raw and provider previews", () => {
|
||||
const longMessage = "X".repeat(260);
|
||||
const observed = buildApiErrorObservationFields(
|
||||
`{"type":"error","error":{"type":"server_error","message":"${longMessage}"},"request_id":"req_long"}`,
|
||||
);
|
||||
|
||||
expect(observed.rawErrorPreview).toBeDefined();
|
||||
expect(observed.providerErrorMessagePreview).toBeDefined();
|
||||
expect(observed.rawErrorPreview?.length).toBeLessThanOrEqual(401);
|
||||
expect(observed.providerErrorMessagePreview?.length).toBeLessThanOrEqual(201);
|
||||
expect(observed.providerErrorMessagePreview?.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
it("caps oversized raw inputs before hashing and fingerprinting", () => {
|
||||
const oversized = "X".repeat(70_000);
|
||||
const bounded = "X".repeat(64_000);
|
||||
|
||||
expect(buildApiErrorObservationFields(oversized)).toMatchObject({
|
||||
rawErrorHash: buildApiErrorObservationFields(bounded).rawErrorHash,
|
||||
rawErrorFingerprint: buildApiErrorObservationFields(bounded).rawErrorFingerprint,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty observation fields for empty input", () => {
|
||||
expect(buildApiErrorObservationFields(undefined)).toEqual({});
|
||||
expect(buildApiErrorObservationFields("")).toEqual({});
|
||||
expect(buildApiErrorObservationFields(" ")).toEqual({});
|
||||
});
|
||||
|
||||
it("re-reads configured redact patterns on each call", () => {
|
||||
const readLoggingConfig = vi.spyOn(loggingConfigModule, "readLoggingConfig");
|
||||
readLoggingConfig.mockReturnValueOnce(undefined);
|
||||
readLoggingConfig.mockReturnValueOnce({
|
||||
redactPatterns: [String.raw`\bcustom-secret-[A-Za-z0-9]+\b`],
|
||||
});
|
||||
|
||||
const first = buildApiErrorObservationFields("custom-secret-abc123");
|
||||
const second = buildApiErrorObservationFields("custom-secret-abc123");
|
||||
|
||||
expect(first.rawErrorPreview).toContain("custom-secret-abc123");
|
||||
expect(second.rawErrorPreview).not.toContain("custom-secret-abc123");
|
||||
expect(second.rawErrorPreview).toContain("custom");
|
||||
});
|
||||
|
||||
it("fails closed when observation sanitization throws", () => {
|
||||
vi.spyOn(loggingConfigModule, "readLoggingConfig").mockImplementation(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
|
||||
expect(buildApiErrorObservationFields("request_id=req_123")).toEqual({});
|
||||
expect(buildTextObservationFields("request_id=req_123")).toEqual({
|
||||
textPreview: undefined,
|
||||
textHash: undefined,
|
||||
textFingerprint: undefined,
|
||||
httpCode: undefined,
|
||||
providerErrorType: undefined,
|
||||
providerErrorMessagePreview: undefined,
|
||||
requestIdHash: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores non-string configured redact patterns", () => {
|
||||
vi.spyOn(loggingConfigModule, "readLoggingConfig").mockReturnValue({
|
||||
redactPatterns: [
|
||||
123 as never,
|
||||
{ bad: true } as never,
|
||||
String.raw`\bcustom-secret-[A-Za-z0-9]+\b`,
|
||||
],
|
||||
});
|
||||
|
||||
const observed = buildApiErrorObservationFields("custom-secret-abc123");
|
||||
|
||||
expect(observed.rawErrorPreview).not.toContain("custom-secret-abc123");
|
||||
expect(observed.rawErrorPreview).toContain("custom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeForConsole", () => {
|
||||
it("strips control characters from console-facing values", () => {
|
||||
expect(sanitizeForConsole("run-1\nprovider\tmodel\rtest")).toBe("run-1 provider model test");
|
||||
});
|
||||
});
|
||||
199
src/agents/pi-embedded-error-observation.ts
Normal file
199
src/agents/pi-embedded-error-observation.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { readLoggingConfig } from "../logging/config.js";
|
||||
import { redactIdentifier } from "../logging/redact-identifier.js";
|
||||
import { getDefaultRedactPatterns, redactSensitiveText } from "../logging/redact.js";
|
||||
import { getApiErrorPayloadFingerprint, parseApiErrorInfo } from "./pi-embedded-helpers.js";
|
||||
import { stableStringify } from "./stable-stringify.js";
|
||||
|
||||
const MAX_OBSERVATION_INPUT_CHARS = 64_000;
|
||||
const MAX_FINGERPRINT_MESSAGE_CHARS = 8_000;
|
||||
const RAW_ERROR_PREVIEW_MAX_CHARS = 400;
|
||||
const PROVIDER_ERROR_PREVIEW_MAX_CHARS = 200;
|
||||
const REQUEST_ID_RE = /\brequest[_ ]?id\b\s*[:=]\s*["'()]*([A-Za-z0-9._:-]+)/i;
|
||||
const OBSERVATION_EXTRA_REDACT_PATTERNS = [
|
||||
String.raw`\b(?:x-)?api[-_]?key\b\s*[:=]\s*(["']?)([^\s"'\\;]+)\1`,
|
||||
String.raw`"(?:api[-_]?key|api_key)"\s*:\s*"([^"]+)"`,
|
||||
String.raw`(?:\bCookie\b\s*[:=]\s*[^;=\s]+=|;\s*[^;=\s]+=)([^;\s\r\n]+)`,
|
||||
];
|
||||
|
||||
function resolveConfiguredRedactPatterns(): string[] {
|
||||
const configured = readLoggingConfig()?.redactPatterns;
|
||||
if (!Array.isArray(configured)) {
|
||||
return [];
|
||||
}
|
||||
return configured.filter((pattern): pattern is string => typeof pattern === "string");
|
||||
}
|
||||
|
||||
function truncateForObservation(text: string | undefined, maxChars: number): string | undefined {
|
||||
const trimmed = text?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed.length > maxChars ? `${trimmed.slice(0, maxChars)}…` : trimmed;
|
||||
}
|
||||
|
||||
function boundObservationInput(text: string | undefined): string | undefined {
|
||||
const trimmed = text?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed.length > MAX_OBSERVATION_INPUT_CHARS
|
||||
? trimmed.slice(0, MAX_OBSERVATION_INPUT_CHARS)
|
||||
: trimmed;
|
||||
}
|
||||
|
||||
export function sanitizeForConsole(text: string | undefined, maxChars = 200): string | undefined {
|
||||
const trimmed = text?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const withoutControlChars = Array.from(trimmed)
|
||||
.filter((char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
return !(
|
||||
code <= 0x08 ||
|
||||
code === 0x0b ||
|
||||
code === 0x0c ||
|
||||
(code >= 0x0e && code <= 0x1f) ||
|
||||
code === 0x7f
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
const sanitized = withoutControlChars
|
||||
.replace(/[\r\n\t]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return sanitized.length > maxChars ? `${sanitized.slice(0, maxChars)}…` : sanitized;
|
||||
}
|
||||
|
||||
function replaceRequestIdPreview(
|
||||
text: string | undefined,
|
||||
requestId: string | undefined,
|
||||
): string | undefined {
|
||||
if (!text || !requestId) {
|
||||
return text;
|
||||
}
|
||||
return text.split(requestId).join(redactIdentifier(requestId, { len: 12 }));
|
||||
}
|
||||
|
||||
function redactObservationText(text: string | undefined): string | undefined {
|
||||
if (!text) {
|
||||
return text;
|
||||
}
|
||||
// Observation logs must stay redacted even when operators disable general-purpose
|
||||
// log redaction, otherwise raw provider payloads leak back into always-on logs.
|
||||
const configuredPatterns = resolveConfiguredRedactPatterns();
|
||||
return redactSensitiveText(text, {
|
||||
mode: "tools",
|
||||
patterns: [
|
||||
...getDefaultRedactPatterns(),
|
||||
...configuredPatterns,
|
||||
...OBSERVATION_EXTRA_REDACT_PATTERNS,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function extractRequestId(text: string | undefined): string | undefined {
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
const match = text.match(REQUEST_ID_RE);
|
||||
return match?.[1]?.trim() || undefined;
|
||||
}
|
||||
|
||||
function buildObservationFingerprint(params: {
|
||||
raw: string;
|
||||
requestId?: string;
|
||||
httpCode?: string;
|
||||
type?: string;
|
||||
message?: string;
|
||||
}): string | null {
|
||||
const boundedMessage =
|
||||
params.message && params.message.length > MAX_FINGERPRINT_MESSAGE_CHARS
|
||||
? params.message.slice(0, MAX_FINGERPRINT_MESSAGE_CHARS)
|
||||
: params.message;
|
||||
const structured =
|
||||
params.httpCode || params.type || boundedMessage
|
||||
? stableStringify({
|
||||
httpCode: params.httpCode,
|
||||
type: params.type,
|
||||
message: boundedMessage,
|
||||
})
|
||||
: null;
|
||||
if (structured) {
|
||||
return structured;
|
||||
}
|
||||
if (params.requestId) {
|
||||
return params.raw.split(params.requestId).join("<request_id>");
|
||||
}
|
||||
return getApiErrorPayloadFingerprint(params.raw);
|
||||
}
|
||||
|
||||
export function buildApiErrorObservationFields(rawError?: string): {
|
||||
rawErrorPreview?: string;
|
||||
rawErrorHash?: string;
|
||||
rawErrorFingerprint?: string;
|
||||
httpCode?: string;
|
||||
providerErrorType?: string;
|
||||
providerErrorMessagePreview?: string;
|
||||
requestIdHash?: string;
|
||||
} {
|
||||
const trimmed = boundObservationInput(rawError);
|
||||
if (!trimmed) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = parseApiErrorInfo(trimmed);
|
||||
const requestId = parsed?.requestId ?? extractRequestId(trimmed);
|
||||
const requestIdHash = requestId ? redactIdentifier(requestId, { len: 12 }) : undefined;
|
||||
const rawFingerprint = buildObservationFingerprint({
|
||||
raw: trimmed,
|
||||
requestId,
|
||||
httpCode: parsed?.httpCode,
|
||||
type: parsed?.type,
|
||||
message: parsed?.message,
|
||||
});
|
||||
const redactedRawPreview = replaceRequestIdPreview(redactObservationText(trimmed), requestId);
|
||||
const redactedProviderMessage = replaceRequestIdPreview(
|
||||
redactObservationText(parsed?.message),
|
||||
requestId,
|
||||
);
|
||||
|
||||
return {
|
||||
rawErrorPreview: truncateForObservation(redactedRawPreview, RAW_ERROR_PREVIEW_MAX_CHARS),
|
||||
rawErrorHash: redactIdentifier(trimmed, { len: 12 }),
|
||||
rawErrorFingerprint: rawFingerprint
|
||||
? redactIdentifier(rawFingerprint, { len: 12 })
|
||||
: undefined,
|
||||
httpCode: parsed?.httpCode,
|
||||
providerErrorType: parsed?.type,
|
||||
providerErrorMessagePreview: truncateForObservation(
|
||||
redactedProviderMessage,
|
||||
PROVIDER_ERROR_PREVIEW_MAX_CHARS,
|
||||
),
|
||||
requestIdHash,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTextObservationFields(text?: string): {
|
||||
textPreview?: string;
|
||||
textHash?: string;
|
||||
textFingerprint?: string;
|
||||
httpCode?: string;
|
||||
providerErrorType?: string;
|
||||
providerErrorMessagePreview?: string;
|
||||
requestIdHash?: string;
|
||||
} {
|
||||
const observed = buildApiErrorObservationFields(text);
|
||||
return {
|
||||
textPreview: observed.rawErrorPreview,
|
||||
textHash: observed.rawErrorHash,
|
||||
textFingerprint: observed.rawErrorFingerprint,
|
||||
httpCode: observed.httpCode,
|
||||
providerErrorType: observed.providerErrorType,
|
||||
providerErrorMessagePreview: observed.providerErrorMessagePreview,
|
||||
requestIdHash: observed.requestIdHash,
|
||||
};
|
||||
}
|
||||
@@ -32,7 +32,7 @@ const OPENROUTER_CREDITS_MESSAGE = "Payment Required: insufficient credits";
|
||||
// Issue-backed Anthropic/OpenAI-compatible insufficient_quota payload under HTTP 400:
|
||||
// https://github.com/openclaw/openclaw/issues/23440
|
||||
const INSUFFICIENT_QUOTA_PAYLOAD =
|
||||
'{"type":"error","error":{"type":"insufficient_quota","message":"Your account has insufficient quota balance to run this request."}}';
|
||||
'{"type":"error","error":{"type":"insufficient_quota","message":"Your account has insufficient quota balance to run this request."}}'; // pragma: allowlist secret
|
||||
// Together AI error code examples: https://docs.together.ai/docs/error-codes
|
||||
const TOGETHER_PAYMENT_REQUIRED_MESSAGE =
|
||||
"402 Payment Required: The account associated with this API key has reached its maximum allowed monthly spending limit.";
|
||||
@@ -42,7 +42,7 @@ const TOGETHER_ENGINE_OVERLOADED_MESSAGE =
|
||||
const GROQ_TOO_MANY_REQUESTS_MESSAGE =
|
||||
"429 Too Many Requests: Too many requests were sent in a given timeframe.";
|
||||
const GROQ_SERVICE_UNAVAILABLE_MESSAGE =
|
||||
"503 Service Unavailable: The server is temporarily unable to handle the request due to overloading or maintenance.";
|
||||
"503 Service Unavailable: The server is temporarily unable to handle the request due to overloading or maintenance."; // pragma: allowlist secret
|
||||
|
||||
describe("isAuthPermanentErrorMessage", () => {
|
||||
it("matches permanent auth failure patterns", () => {
|
||||
@@ -443,6 +443,7 @@ describe("isLikelyContextOverflowError", () => {
|
||||
|
||||
describe("isTransientHttpError", () => {
|
||||
it("returns true for retryable 5xx status codes", () => {
|
||||
expect(isTransientHttpError("499 Client Closed Request")).toBe(true);
|
||||
expect(isTransientHttpError("500 Internal Server Error")).toBe(true);
|
||||
expect(isTransientHttpError("502 Bad Gateway")).toBe(true);
|
||||
expect(isTransientHttpError("503 Service Unavailable")).toBe(true);
|
||||
@@ -457,6 +458,19 @@ describe("isTransientHttpError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyFailoverReasonFromHttpStatus", () => {
|
||||
it("treats HTTP 499 as transient for structured errors", () => {
|
||||
expect(classifyFailoverReasonFromHttpStatus(499)).toBe("timeout");
|
||||
expect(classifyFailoverReasonFromHttpStatus(499, "499 Client Closed Request")).toBe("timeout");
|
||||
expect(
|
||||
classifyFailoverReasonFromHttpStatus(
|
||||
499,
|
||||
'{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}',
|
||||
),
|
||||
).toBe("overloaded");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFailoverErrorMessage", () => {
|
||||
it("matches auth/rate/billing/timeout", () => {
|
||||
const samples = [
|
||||
|
||||
@@ -189,7 +189,7 @@ const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i;
|
||||
const HTTP_STATUS_CODE_PREFIX_RE = /^(?:http\s*)?(\d{3})(?:\s+([\s\S]+))?$/i;
|
||||
const HTML_ERROR_PREFIX_RE = /^\s*(?:<!doctype\s+html\b|<html\b)/i;
|
||||
const CLOUDFLARE_HTML_ERROR_CODES = new Set([521, 522, 523, 524, 525, 526, 530]);
|
||||
const TRANSIENT_HTTP_ERROR_CODES = new Set([500, 502, 503, 504, 521, 522, 523, 524, 529]);
|
||||
const TRANSIENT_HTTP_ERROR_CODES = new Set([499, 500, 502, 503, 504, 521, 522, 523, 524, 529]);
|
||||
const HTTP_ERROR_HINTS = [
|
||||
"error",
|
||||
"bad request",
|
||||
@@ -375,6 +375,12 @@ export function classifyFailoverReasonFromHttpStatus(
|
||||
}
|
||||
return "timeout";
|
||||
}
|
||||
if (status === 499) {
|
||||
if (message && isOverloadedErrorMessage(message)) {
|
||||
return "overloaded";
|
||||
}
|
||||
return "timeout";
|
||||
}
|
||||
if (status === 502 || status === 504) {
|
||||
return "timeout";
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { registerLogTransport, resetLogger, setLoggerOverride } from "../logging/logger.js";
|
||||
import { redactIdentifier } from "../logging/redact-identifier.js";
|
||||
import type { AuthProfileFailureReason } from "./auth-profiles.js";
|
||||
import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js";
|
||||
|
||||
@@ -51,6 +53,7 @@ vi.mock("./models-config.js", async (importOriginal) => {
|
||||
});
|
||||
|
||||
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner/run.js").runEmbeddedPiAgent;
|
||||
let unregisterLogTransport: (() => void) | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner/run.js"));
|
||||
@@ -64,6 +67,13 @@ beforeEach(() => {
|
||||
sleepWithAbortMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unregisterLogTransport?.();
|
||||
unregisterLogTransport = undefined;
|
||||
setLoggerOverride(null);
|
||||
resetLogger();
|
||||
});
|
||||
|
||||
const baseUsage = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
@@ -720,6 +730,61 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
expect(sleepWithAbortMock).toHaveBeenCalledWith(321, undefined);
|
||||
});
|
||||
|
||||
it("logs structured failover decision metadata for overloaded assistant rotation", async () => {
|
||||
const records: Array<Record<string, unknown>> = [];
|
||||
setLoggerOverride({
|
||||
level: "trace",
|
||||
consoleLevel: "silent",
|
||||
file: path.join(os.tmpdir(), `openclaw-auth-rotation-${Date.now()}.log`),
|
||||
});
|
||||
unregisterLogTransport = registerLogTransport((record) => {
|
||||
records.push(record);
|
||||
});
|
||||
|
||||
await runAutoPinnedRotationCase({
|
||||
errorMessage:
|
||||
'{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_overload"}',
|
||||
sessionKey: "agent:test:overloaded-logging",
|
||||
runId: "run:overloaded-logging",
|
||||
});
|
||||
|
||||
const decisionRecord = records.find(
|
||||
(record) =>
|
||||
record["2"] === "embedded run failover decision" &&
|
||||
record["1"] &&
|
||||
typeof record["1"] === "object" &&
|
||||
(record["1"] as Record<string, unknown>).decision === "rotate_profile",
|
||||
);
|
||||
|
||||
expect(decisionRecord).toBeDefined();
|
||||
const safeProfileId = redactIdentifier("openai:p1", { len: 12 });
|
||||
expect((decisionRecord as Record<string, unknown>)["1"]).toMatchObject({
|
||||
event: "embedded_run_failover_decision",
|
||||
runId: "run:overloaded-logging",
|
||||
decision: "rotate_profile",
|
||||
failoverReason: "overloaded",
|
||||
profileId: safeProfileId,
|
||||
providerErrorType: "overloaded_error",
|
||||
rawErrorPreview: expect.stringContaining('"request_id":"sha256:'),
|
||||
});
|
||||
|
||||
const stateRecord = records.find(
|
||||
(record) =>
|
||||
record["2"] === "auth profile failure state updated" &&
|
||||
record["1"] &&
|
||||
typeof record["1"] === "object" &&
|
||||
(record["1"] as Record<string, unknown>).profileId === safeProfileId,
|
||||
);
|
||||
|
||||
expect(stateRecord).toBeDefined();
|
||||
expect((stateRecord as Record<string, unknown>)["1"]).toMatchObject({
|
||||
event: "auth_profile_failure_state_updated",
|
||||
runId: "run:overloaded-logging",
|
||||
profileId: safeProfileId,
|
||||
reason: "overloaded",
|
||||
});
|
||||
});
|
||||
|
||||
it("rotates for overloaded prompt failures across auto-pinned profiles", async () => {
|
||||
const { usageStats } = await runAutoPinnedPromptErrorRotationCase({
|
||||
errorMessage: '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}',
|
||||
@@ -1013,6 +1078,54 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("can probe one billing-disabled profile when transient cooldown probe is allowed without fallback models", async () => {
|
||||
await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => {
|
||||
await writeAuthStore(agentDir, {
|
||||
usageStats: {
|
||||
"openai:p1": {
|
||||
lastUsed: 1,
|
||||
disabledUntil: now + 60 * 60 * 1000,
|
||||
disabledReason: "billing",
|
||||
},
|
||||
"openai:p2": {
|
||||
lastUsed: 2,
|
||||
disabledUntil: now + 60 * 60 * 1000,
|
||||
disabledReason: "billing",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
runEmbeddedAttemptMock.mockResolvedValueOnce(
|
||||
makeAttempt({
|
||||
assistantTexts: ["ok"],
|
||||
lastAssistant: buildAssistant({
|
||||
stopReason: "stop",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:billing-cooldown-probe-no-fallbacks",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig(),
|
||||
prompt: "hello",
|
||||
provider: "openai",
|
||||
model: "mock-1",
|
||||
authProfileIdSource: "auto",
|
||||
allowTransientCooldownProbe: true,
|
||||
timeoutMs: 5_000,
|
||||
runId: "run:billing-cooldown-probe-no-fallbacks",
|
||||
});
|
||||
|
||||
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.payloads?.[0]?.text ?? "").toContain("ok");
|
||||
});
|
||||
});
|
||||
|
||||
it("treats agent-level fallbacks as configured when defaults have none", async () => {
|
||||
await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => {
|
||||
await writeAuthStore(agentDir, {
|
||||
|
||||
@@ -61,6 +61,7 @@ import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
|
||||
import { log } from "./logger.js";
|
||||
import { resolveModel } from "./model.js";
|
||||
import { runEmbeddedAttempt } from "./run/attempt.js";
|
||||
import { createFailoverDecisionLogger } from "./run/failover-observation.js";
|
||||
import type { RunEmbeddedPiAgentParams } from "./run/params.js";
|
||||
import { buildEmbeddedRunPayloads } from "./run/payloads.js";
|
||||
import {
|
||||
@@ -762,6 +763,7 @@ export async function runEmbeddedPiAgent(
|
||||
reason,
|
||||
cfg: params.config,
|
||||
agentDir,
|
||||
runId: params.runId,
|
||||
});
|
||||
};
|
||||
const resolveAuthProfileFailureReason = (
|
||||
@@ -848,6 +850,7 @@ export async function runEmbeddedPiAgent(
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
trigger: params.trigger,
|
||||
memoryFlushWritePath: params.memoryFlushWritePath,
|
||||
messageChannel: params.messageChannel,
|
||||
messageProvider: params.messageProvider,
|
||||
agentAccountId: params.agentAccountId,
|
||||
@@ -1226,11 +1229,26 @@ export async function runEmbeddedPiAgent(
|
||||
reason: promptProfileFailureReason,
|
||||
});
|
||||
const promptFailoverFailure = isFailoverErrorMessage(errorText);
|
||||
// Capture the failing profile before auth-profile rotation mutates `lastProfileId`.
|
||||
const failedPromptProfileId = lastProfileId;
|
||||
const logPromptFailoverDecision = createFailoverDecisionLogger({
|
||||
stage: "prompt",
|
||||
runId: params.runId,
|
||||
rawError: errorText,
|
||||
failoverReason: promptFailoverReason,
|
||||
profileFailureReason: promptProfileFailureReason,
|
||||
provider,
|
||||
model: modelId,
|
||||
profileId: failedPromptProfileId,
|
||||
fallbackConfigured,
|
||||
aborted,
|
||||
});
|
||||
if (
|
||||
promptFailoverFailure &&
|
||||
promptFailoverReason !== "timeout" &&
|
||||
(await advanceAuthProfile())
|
||||
) {
|
||||
logPromptFailoverDecision("rotate_profile");
|
||||
await maybeBackoffBeforeOverloadFailover(promptFailoverReason);
|
||||
continue;
|
||||
}
|
||||
@@ -1249,15 +1267,20 @@ export async function runEmbeddedPiAgent(
|
||||
// are configured so outer model fallback can continue on overload,
|
||||
// rate-limit, auth, or billing failures.
|
||||
if (fallbackConfigured && promptFailoverFailure) {
|
||||
const status = resolveFailoverStatus(promptFailoverReason ?? "unknown");
|
||||
logPromptFailoverDecision("fallback_model", { status });
|
||||
await maybeBackoffBeforeOverloadFailover(promptFailoverReason);
|
||||
throw new FailoverError(errorText, {
|
||||
reason: promptFailoverReason ?? "unknown",
|
||||
provider,
|
||||
model: modelId,
|
||||
profileId: lastProfileId,
|
||||
status: resolveFailoverStatus(promptFailoverReason ?? "unknown"),
|
||||
status,
|
||||
});
|
||||
}
|
||||
if (promptFailoverFailure || promptFailoverReason) {
|
||||
logPromptFailoverDecision("surface_error");
|
||||
}
|
||||
throw promptError;
|
||||
}
|
||||
|
||||
@@ -1282,6 +1305,21 @@ export async function runEmbeddedPiAgent(
|
||||
resolveAuthProfileFailureReason(assistantFailoverReason);
|
||||
const cloudCodeAssistFormatError = attempt.cloudCodeAssistFormatError;
|
||||
const imageDimensionError = parseImageDimensionError(lastAssistant?.errorMessage ?? "");
|
||||
// Capture the failing profile before auth-profile rotation mutates `lastProfileId`.
|
||||
const failedAssistantProfileId = lastProfileId;
|
||||
const logAssistantFailoverDecision = createFailoverDecisionLogger({
|
||||
stage: "assistant",
|
||||
runId: params.runId,
|
||||
rawError: lastAssistant?.errorMessage?.trim(),
|
||||
failoverReason: assistantFailoverReason,
|
||||
profileFailureReason: assistantProfileFailureReason,
|
||||
provider: activeErrorContext.provider,
|
||||
model: activeErrorContext.model,
|
||||
profileId: failedAssistantProfileId,
|
||||
fallbackConfigured,
|
||||
timedOut,
|
||||
aborted,
|
||||
});
|
||||
|
||||
if (
|
||||
authFailure &&
|
||||
@@ -1339,6 +1377,7 @@ export async function runEmbeddedPiAgent(
|
||||
|
||||
const rotated = await advanceAuthProfile();
|
||||
if (rotated) {
|
||||
logAssistantFailoverDecision("rotate_profile");
|
||||
await maybeBackoffBeforeOverloadFailover(assistantFailoverReason);
|
||||
continue;
|
||||
}
|
||||
@@ -1371,6 +1410,7 @@ export async function runEmbeddedPiAgent(
|
||||
const status =
|
||||
resolveFailoverStatus(assistantFailoverReason ?? "unknown") ??
|
||||
(isTimeoutErrorMessage(message) ? 408 : undefined);
|
||||
logAssistantFailoverDecision("fallback_model", { status });
|
||||
throw new FailoverError(message, {
|
||||
reason: assistantFailoverReason ?? "unknown",
|
||||
provider: activeErrorContext.provider,
|
||||
@@ -1379,6 +1419,7 @@ export async function runEmbeddedPiAgent(
|
||||
status,
|
||||
});
|
||||
}
|
||||
logAssistantFailoverDecision("surface_error");
|
||||
}
|
||||
|
||||
const usage = toNormalizedUsage(usageAccumulator);
|
||||
@@ -1417,6 +1458,7 @@ export async function runEmbeddedPiAgent(
|
||||
suppressToolErrorWarnings: params.suppressToolErrorWarnings,
|
||||
inlineToolResultsAllowed: false,
|
||||
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
|
||||
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
|
||||
});
|
||||
|
||||
// Timeout aborts can leave the run without any assistant payloads.
|
||||
@@ -1439,6 +1481,7 @@ export async function runEmbeddedPiAgent(
|
||||
systemPromptReport: attempt.systemPromptReport,
|
||||
},
|
||||
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
|
||||
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
|
||||
messagingToolSentTexts: attempt.messagingToolSentTexts,
|
||||
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
|
||||
messagingToolSentTargets: attempt.messagingToolSentTargets,
|
||||
@@ -1486,6 +1529,7 @@ export async function runEmbeddedPiAgent(
|
||||
: undefined,
|
||||
},
|
||||
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
|
||||
didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt,
|
||||
messagingToolSentTexts: attempt.messagingToolSentTexts,
|
||||
messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls,
|
||||
messagingToolSentTargets: attempt.messagingToolSentTargets,
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import type {
|
||||
AuthStorage,
|
||||
ExtensionContext,
|
||||
ModelRegistry,
|
||||
ToolDefinition,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createHostSandboxFsBridge } from "../../test-helpers/host-sandbox-fs-bridge.js";
|
||||
import { createPiToolsSandboxContext } from "../../test-helpers/pi-tools-sandbox-context.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const spawnSubagentDirectMock = vi.fn();
|
||||
const createAgentSessionMock = vi.fn();
|
||||
const sessionManagerOpenMock = vi.fn();
|
||||
const resolveSandboxContextMock = vi.fn();
|
||||
const subscribeEmbeddedPiSessionMock = vi.fn();
|
||||
const acquireSessionWriteLockMock = vi.fn();
|
||||
const sessionManager = {
|
||||
getLeafEntry: vi.fn(() => null),
|
||||
branch: vi.fn(),
|
||||
resetLeaf: vi.fn(),
|
||||
buildSessionContext: vi.fn(() => ({ messages: [] })),
|
||||
appendCustomEntry: vi.fn(),
|
||||
};
|
||||
return {
|
||||
spawnSubagentDirectMock,
|
||||
createAgentSessionMock,
|
||||
sessionManagerOpenMock,
|
||||
resolveSandboxContextMock,
|
||||
subscribeEmbeddedPiSessionMock,
|
||||
acquireSessionWriteLockMock,
|
||||
sessionManager,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@mariozechner/pi-coding-agent")>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
createAgentSession: (...args: unknown[]) => hoisted.createAgentSessionMock(...args),
|
||||
DefaultResourceLoader: class {
|
||||
async reload() {}
|
||||
},
|
||||
SessionManager: {
|
||||
open: (...args: unknown[]) => hoisted.sessionManagerOpenMock(...args),
|
||||
} as unknown as typeof actual.SessionManager,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../subagent-spawn.js", () => ({
|
||||
SUBAGENT_SPAWN_MODES: ["run", "session"],
|
||||
spawnSubagentDirect: (...args: unknown[]) => hoisted.spawnSubagentDirectMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../sandbox.js", () => ({
|
||||
resolveSandboxContext: (...args: unknown[]) => hoisted.resolveSandboxContextMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../session-tool-result-guard-wrapper.js", () => ({
|
||||
guardSessionManager: () => hoisted.sessionManager,
|
||||
}));
|
||||
|
||||
vi.mock("../../pi-embedded-subscribe.js", () => ({
|
||||
subscribeEmbeddedPiSession: (...args: unknown[]) =>
|
||||
hoisted.subscribeEmbeddedPiSessionMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../plugins/hook-runner-global.js", () => ({
|
||||
getGlobalHookRunner: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../../../infra/machine-name.js", () => ({
|
||||
getMachineDisplayName: async () => "test-host",
|
||||
}));
|
||||
|
||||
vi.mock("../../../infra/net/undici-global-dispatcher.js", () => ({
|
||||
ensureGlobalUndiciStreamTimeouts: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../bootstrap-files.js", () => ({
|
||||
makeBootstrapWarn: () => () => {},
|
||||
resolveBootstrapContextForRun: async () => ({ bootstrapFiles: [], contextFiles: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../skills.js", () => ({
|
||||
applySkillEnvOverrides: () => () => {},
|
||||
applySkillEnvOverridesFromSnapshot: () => () => {},
|
||||
resolveSkillsPromptForRun: () => "",
|
||||
}));
|
||||
|
||||
vi.mock("../skills-runtime.js", () => ({
|
||||
resolveEmbeddedRunSkillEntries: () => ({
|
||||
shouldLoadSkillEntries: false,
|
||||
skillEntries: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../docs-path.js", () => ({
|
||||
resolveOpenClawDocsPath: async () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../../pi-project-settings.js", () => ({
|
||||
createPreparedEmbeddedPiSettingsManager: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../pi-settings.js", () => ({
|
||||
applyPiAutoCompactionGuard: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../extensions.js", () => ({
|
||||
buildEmbeddedExtensionFactories: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("../google.js", () => ({
|
||||
logToolSchemasForGoogle: () => {},
|
||||
sanitizeSessionHistory: async ({ messages }: { messages: unknown[] }) => messages,
|
||||
sanitizeToolsForGoogle: ({ tools }: { tools: unknown[] }) => tools,
|
||||
}));
|
||||
|
||||
vi.mock("../../session-file-repair.js", () => ({
|
||||
repairSessionFileIfNeeded: async () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../session-manager-cache.js", () => ({
|
||||
prewarmSessionFile: async () => {},
|
||||
trackSessionManagerAccess: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../session-manager-init.js", () => ({
|
||||
prepareSessionManagerForRun: async () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../session-write-lock.js", () => ({
|
||||
acquireSessionWriteLock: (...args: unknown[]) => hoisted.acquireSessionWriteLockMock(...args),
|
||||
resolveSessionLockMaxHoldFromTimeout: () => 1,
|
||||
}));
|
||||
|
||||
vi.mock("../tool-result-context-guard.js", () => ({
|
||||
installToolResultContextGuard: () => () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../wait-for-idle-before-flush.js", () => ({
|
||||
flushPendingToolResultsAfterIdle: async () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../runs.js", () => ({
|
||||
setActiveEmbeddedRun: () => {},
|
||||
clearActiveEmbeddedRun: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("./images.js", () => ({
|
||||
detectAndLoadPromptImages: async () => ({ images: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../system-prompt-params.js", () => ({
|
||||
buildSystemPromptParams: () => ({
|
||||
runtimeInfo: {},
|
||||
userTimezone: "UTC",
|
||||
userTime: "00:00",
|
||||
userTimeFormat: "24h",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../system-prompt-report.js", () => ({
|
||||
buildSystemPromptReport: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../system-prompt.js", () => ({
|
||||
applySystemPromptOverrideToSession: () => {},
|
||||
buildEmbeddedSystemPrompt: () => "system prompt",
|
||||
createSystemPromptOverride: (prompt: string) => () => prompt,
|
||||
}));
|
||||
|
||||
vi.mock("../extra-params.js", () => ({
|
||||
applyExtraParamsToAgent: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../openai-ws-stream.js", () => ({
|
||||
createOpenAIWebSocketStreamFn: vi.fn(),
|
||||
releaseWsSession: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../anthropic-payload-log.js", () => ({
|
||||
createAnthropicPayloadLogger: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../../cache-trace.js", () => ({
|
||||
createCacheTrace: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../../model-selection.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../model-selection.js")>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
normalizeProviderId: (providerId?: string) => providerId?.trim().toLowerCase() ?? "",
|
||||
resolveDefaultModelForAgent: () => ({ provider: "openai", model: "gpt-test" }),
|
||||
};
|
||||
});
|
||||
|
||||
const { runEmbeddedAttempt } = await import("./attempt.js");
|
||||
|
||||
type MutableSession = {
|
||||
sessionId: string;
|
||||
messages: unknown[];
|
||||
isCompacting: boolean;
|
||||
isStreaming: boolean;
|
||||
agent: {
|
||||
streamFn?: unknown;
|
||||
replaceMessages: (messages: unknown[]) => void;
|
||||
};
|
||||
prompt: (prompt: string, options?: { images?: unknown[] }) => Promise<void>;
|
||||
abort: () => Promise<void>;
|
||||
dispose: () => void;
|
||||
steer: (text: string) => Promise<void>;
|
||||
};
|
||||
|
||||
function createSubscriptionMock() {
|
||||
return {
|
||||
assistantTexts: [] as string[],
|
||||
toolMetas: [] as Array<{ toolName: string; meta?: string }>,
|
||||
unsubscribe: () => {},
|
||||
waitForCompactionRetry: async () => {},
|
||||
getMessagingToolSentTexts: () => [] as string[],
|
||||
getMessagingToolSentMediaUrls: () => [] as string[],
|
||||
getMessagingToolSentTargets: () => [] as unknown[],
|
||||
getSuccessfulCronAdds: () => 0,
|
||||
didSendViaMessagingTool: () => false,
|
||||
didSendDeterministicApprovalPrompt: () => false,
|
||||
getLastToolError: () => undefined,
|
||||
getUsageTotals: () => undefined,
|
||||
getCompactionCount: () => 0,
|
||||
isCompacting: () => false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("runEmbeddedAttempt sessions_spawn workspace inheritance", () => {
|
||||
const tempPaths: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
hoisted.spawnSubagentDirectMock.mockReset().mockResolvedValue({
|
||||
status: "accepted",
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
runId: "run-child",
|
||||
});
|
||||
hoisted.createAgentSessionMock.mockReset();
|
||||
hoisted.sessionManagerOpenMock.mockReset().mockReturnValue(hoisted.sessionManager);
|
||||
hoisted.resolveSandboxContextMock.mockReset();
|
||||
hoisted.subscribeEmbeddedPiSessionMock.mockReset().mockImplementation(createSubscriptionMock);
|
||||
hoisted.acquireSessionWriteLockMock.mockReset().mockResolvedValue({
|
||||
release: async () => {},
|
||||
});
|
||||
hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null);
|
||||
hoisted.sessionManager.branch.mockReset();
|
||||
hoisted.sessionManager.resetLeaf.mockReset();
|
||||
hoisted.sessionManager.buildSessionContext.mockReset().mockReturnValue({ messages: [] });
|
||||
hoisted.sessionManager.appendCustomEntry.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
while (tempPaths.length > 0) {
|
||||
const target = tempPaths.pop();
|
||||
if (target) {
|
||||
await fs.rm(target, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("passes the real workspace to sessions_spawn when workspaceAccess is ro", async () => {
|
||||
const realWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-real-workspace-"));
|
||||
const sandboxWorkspace = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "openclaw-sandbox-workspace-"),
|
||||
);
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-dir-"));
|
||||
tempPaths.push(realWorkspace, sandboxWorkspace, agentDir);
|
||||
|
||||
hoisted.resolveSandboxContextMock.mockResolvedValue(
|
||||
createPiToolsSandboxContext({
|
||||
workspaceDir: sandboxWorkspace,
|
||||
agentWorkspaceDir: realWorkspace,
|
||||
workspaceAccess: "ro",
|
||||
fsBridge: createHostSandboxFsBridge(sandboxWorkspace),
|
||||
tools: { allow: ["sessions_spawn"], deny: [] },
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
);
|
||||
|
||||
hoisted.createAgentSessionMock.mockImplementation(
|
||||
async (params: { customTools: ToolDefinition[] }) => {
|
||||
const session: MutableSession = {
|
||||
sessionId: "embedded-session",
|
||||
messages: [],
|
||||
isCompacting: false,
|
||||
isStreaming: false,
|
||||
agent: {
|
||||
replaceMessages: (messages: unknown[]) => {
|
||||
session.messages = [...messages];
|
||||
},
|
||||
},
|
||||
prompt: async () => {
|
||||
const spawnTool = params.customTools.find((tool) => tool.name === "sessions_spawn");
|
||||
expect(spawnTool).toBeDefined();
|
||||
if (!spawnTool) {
|
||||
throw new Error("missing sessions_spawn tool");
|
||||
}
|
||||
await spawnTool.execute(
|
||||
"call-sessions-spawn",
|
||||
{ task: "inspect workspace" },
|
||||
undefined,
|
||||
undefined,
|
||||
{} as unknown as ExtensionContext,
|
||||
);
|
||||
},
|
||||
abort: async () => {},
|
||||
dispose: () => {},
|
||||
steer: async () => {},
|
||||
};
|
||||
|
||||
return { session };
|
||||
},
|
||||
);
|
||||
|
||||
const model = {
|
||||
api: "openai-completions",
|
||||
provider: "openai",
|
||||
compat: {},
|
||||
contextWindow: 8192,
|
||||
input: ["text"],
|
||||
} as unknown as Model<Api>;
|
||||
|
||||
const result = await runEmbeddedAttempt({
|
||||
sessionId: "embedded-session",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionFile: path.join(realWorkspace, "session.jsonl"),
|
||||
workspaceDir: realWorkspace,
|
||||
agentDir,
|
||||
config: {},
|
||||
prompt: "spawn a child session",
|
||||
timeoutMs: 10_000,
|
||||
runId: "run-1",
|
||||
provider: "openai",
|
||||
modelId: "gpt-test",
|
||||
model,
|
||||
authStorage: {} as AuthStorage,
|
||||
modelRegistry: {} as ModelRegistry,
|
||||
thinkLevel: "off",
|
||||
senderIsOwner: true,
|
||||
disableMessageTool: true,
|
||||
});
|
||||
|
||||
expect(result.promptError).toBeNull();
|
||||
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
task: "inspect workspace",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
workspaceDir: realWorkspace,
|
||||
}),
|
||||
);
|
||||
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
workspaceDir: sandboxWorkspace,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -869,7 +869,13 @@ export async function runEmbeddedAttempt(
|
||||
runId: params.runId,
|
||||
agentDir,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
// When sandboxing uses a copied workspace (`ro` or `none`), effectiveWorkspace points
|
||||
// at the sandbox copy. Spawned subagents should inherit the real workspace instead.
|
||||
spawnWorkspaceDir:
|
||||
sandbox?.enabled && sandbox.workspaceAccess !== "rw" ? resolvedWorkspace : undefined,
|
||||
config: params.config,
|
||||
trigger: params.trigger,
|
||||
memoryFlushWritePath: params.memoryFlushWritePath,
|
||||
abortSignal: runAbortController.signal,
|
||||
modelProvider: params.model.provider,
|
||||
modelId: params.modelId,
|
||||
@@ -1544,6 +1550,7 @@ export async function runEmbeddedAttempt(
|
||||
getMessagingToolSentTargets,
|
||||
getSuccessfulCronAdds,
|
||||
didSendViaMessagingTool,
|
||||
didSendDeterministicApprovalPrompt,
|
||||
getLastToolError,
|
||||
getUsageTotals,
|
||||
getCompactionCount,
|
||||
@@ -2058,6 +2065,7 @@ export async function runEmbeddedAttempt(
|
||||
lastAssistant,
|
||||
lastToolError: getLastToolError?.(),
|
||||
didSendViaMessagingTool: didSendViaMessagingTool(),
|
||||
didSendDeterministicApprovalPrompt: didSendDeterministicApprovalPrompt(),
|
||||
messagingToolSentTexts: getMessagingToolSentTexts(),
|
||||
messagingToolSentMediaUrls: getMessagingToolSentMediaUrls(),
|
||||
messagingToolSentTargets: getMessagingToolSentTargets(),
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeFailoverDecisionObservationBase } from "./failover-observation.js";
|
||||
|
||||
describe("normalizeFailoverDecisionObservationBase", () => {
|
||||
it("fills timeout observation reasons for deadline timeouts without provider error text", () => {
|
||||
expect(
|
||||
normalizeFailoverDecisionObservationBase({
|
||||
stage: "assistant",
|
||||
runId: "run:timeout",
|
||||
rawError: "",
|
||||
failoverReason: null,
|
||||
profileFailureReason: null,
|
||||
provider: "openai",
|
||||
model: "mock-1",
|
||||
profileId: "openai:p1",
|
||||
fallbackConfigured: false,
|
||||
timedOut: true,
|
||||
aborted: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
failoverReason: "timeout",
|
||||
profileFailureReason: "timeout",
|
||||
timedOut: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit failover reasons", () => {
|
||||
expect(
|
||||
normalizeFailoverDecisionObservationBase({
|
||||
stage: "assistant",
|
||||
runId: "run:overloaded",
|
||||
rawError: '{"error":{"type":"overloaded_error"}}',
|
||||
failoverReason: "overloaded",
|
||||
profileFailureReason: "overloaded",
|
||||
provider: "openai",
|
||||
model: "mock-1",
|
||||
profileId: "openai:p1",
|
||||
fallbackConfigured: true,
|
||||
timedOut: true,
|
||||
aborted: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
failoverReason: "overloaded",
|
||||
profileFailureReason: "overloaded",
|
||||
timedOut: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
76
src/agents/pi-embedded-runner/run/failover-observation.ts
Normal file
76
src/agents/pi-embedded-runner/run/failover-observation.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { redactIdentifier } from "../../../logging/redact-identifier.js";
|
||||
import type { AuthProfileFailureReason } from "../../auth-profiles.js";
|
||||
import {
|
||||
buildApiErrorObservationFields,
|
||||
sanitizeForConsole,
|
||||
} from "../../pi-embedded-error-observation.js";
|
||||
import type { FailoverReason } from "../../pi-embedded-helpers.js";
|
||||
import { log } from "../logger.js";
|
||||
|
||||
export type FailoverDecisionLoggerInput = {
|
||||
stage: "prompt" | "assistant";
|
||||
decision: "rotate_profile" | "fallback_model" | "surface_error";
|
||||
runId?: string;
|
||||
rawError?: string;
|
||||
failoverReason: FailoverReason | null;
|
||||
profileFailureReason?: AuthProfileFailureReason | null;
|
||||
provider: string;
|
||||
model: string;
|
||||
profileId?: string;
|
||||
fallbackConfigured: boolean;
|
||||
timedOut?: boolean;
|
||||
aborted?: boolean;
|
||||
status?: number;
|
||||
};
|
||||
|
||||
export type FailoverDecisionLoggerBase = Omit<FailoverDecisionLoggerInput, "decision" | "status">;
|
||||
|
||||
export function normalizeFailoverDecisionObservationBase(
|
||||
base: FailoverDecisionLoggerBase,
|
||||
): FailoverDecisionLoggerBase {
|
||||
return {
|
||||
...base,
|
||||
failoverReason: base.failoverReason ?? (base.timedOut ? "timeout" : null),
|
||||
profileFailureReason: base.profileFailureReason ?? (base.timedOut ? "timeout" : null),
|
||||
};
|
||||
}
|
||||
|
||||
export function createFailoverDecisionLogger(
|
||||
base: FailoverDecisionLoggerBase,
|
||||
): (
|
||||
decision: FailoverDecisionLoggerInput["decision"],
|
||||
extra?: Pick<FailoverDecisionLoggerInput, "status">,
|
||||
) => void {
|
||||
const normalizedBase = normalizeFailoverDecisionObservationBase(base);
|
||||
const safeProfileId = normalizedBase.profileId
|
||||
? redactIdentifier(normalizedBase.profileId, { len: 12 })
|
||||
: undefined;
|
||||
const safeRunId = sanitizeForConsole(normalizedBase.runId) ?? "-";
|
||||
const safeProvider = sanitizeForConsole(normalizedBase.provider) ?? "-";
|
||||
const safeModel = sanitizeForConsole(normalizedBase.model) ?? "-";
|
||||
const profileText = safeProfileId ?? "-";
|
||||
const reasonText = normalizedBase.failoverReason ?? "none";
|
||||
return (decision, extra) => {
|
||||
const observedError = buildApiErrorObservationFields(normalizedBase.rawError);
|
||||
log.warn("embedded run failover decision", {
|
||||
event: "embedded_run_failover_decision",
|
||||
tags: ["error_handling", "failover", normalizedBase.stage, decision],
|
||||
runId: normalizedBase.runId,
|
||||
stage: normalizedBase.stage,
|
||||
decision,
|
||||
failoverReason: normalizedBase.failoverReason,
|
||||
profileFailureReason: normalizedBase.profileFailureReason,
|
||||
provider: normalizedBase.provider,
|
||||
model: normalizedBase.model,
|
||||
profileId: safeProfileId,
|
||||
fallbackConfigured: normalizedBase.fallbackConfigured,
|
||||
timedOut: normalizedBase.timedOut,
|
||||
aborted: normalizedBase.aborted,
|
||||
status: extra?.status,
|
||||
...observedError,
|
||||
consoleMessage:
|
||||
`embedded run failover decision: runId=${safeRunId} stage=${normalizedBase.stage} decision=${decision} ` +
|
||||
`reason=${reasonText} provider=${safeProvider}/${safeModel} profile=${profileText}`,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js";
|
||||
import type { ReplyPayload } from "../../../auto-reply/types.js";
|
||||
import type { AgentStreamParams } from "../../../commands/agent/types.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import type { enqueueCommand } from "../../../process/command-queue.js";
|
||||
@@ -28,6 +29,8 @@ export type RunEmbeddedPiAgentParams = {
|
||||
agentAccountId?: string;
|
||||
/** What initiated this agent run: "user", "heartbeat", "cron", or "memory". */
|
||||
trigger?: string;
|
||||
/** Relative workspace path that memory-triggered writes are allowed to append to. */
|
||||
memoryFlushWritePath?: string;
|
||||
/** Delivery target (e.g. telegram:group:123:topic:456) for topic/thread routing. */
|
||||
messageTo?: string;
|
||||
/** Thread/topic identifier for routing replies to the originating thread. */
|
||||
@@ -104,7 +107,7 @@ export type RunEmbeddedPiAgentParams = {
|
||||
blockReplyChunking?: BlockReplyChunking;
|
||||
onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
|
||||
onReasoningEnd?: () => void | Promise<void>;
|
||||
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
|
||||
onToolResult?: (payload: ReplyPayload) => void | Promise<void>;
|
||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
||||
lane?: string;
|
||||
enqueue?: typeof enqueueCommand;
|
||||
|
||||
@@ -82,4 +82,13 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => {
|
||||
|
||||
expect(payloads).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("suppresses assistant text when a deterministic exec approval prompt was already delivered", () => {
|
||||
const payloads = buildPayloads({
|
||||
assistantTexts: ["Approval is needed. Please run /approve abc allow-once"],
|
||||
didSendDeterministicApprovalPrompt: true,
|
||||
});
|
||||
|
||||
expect(payloads).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,6 +102,7 @@ export function buildEmbeddedRunPayloads(params: {
|
||||
suppressToolErrorWarnings?: boolean;
|
||||
inlineToolResultsAllowed: boolean;
|
||||
didSendViaMessagingTool?: boolean;
|
||||
didSendDeterministicApprovalPrompt?: boolean;
|
||||
}): Array<{
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
@@ -125,14 +126,17 @@ export function buildEmbeddedRunPayloads(params: {
|
||||
}> = [];
|
||||
|
||||
const useMarkdown = params.toolResultFormat === "markdown";
|
||||
const suppressAssistantArtifacts = params.didSendDeterministicApprovalPrompt === true;
|
||||
const lastAssistantErrored = params.lastAssistant?.stopReason === "error";
|
||||
const errorText = params.lastAssistant
|
||||
? formatAssistantErrorText(params.lastAssistant, {
|
||||
cfg: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
})
|
||||
? suppressAssistantArtifacts
|
||||
? undefined
|
||||
: formatAssistantErrorText(params.lastAssistant, {
|
||||
cfg: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
})
|
||||
: undefined;
|
||||
const rawErrorMessage = lastAssistantErrored
|
||||
? params.lastAssistant?.errorMessage?.trim() || undefined
|
||||
@@ -184,8 +188,9 @@ export function buildEmbeddedRunPayloads(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const reasoningText =
|
||||
params.lastAssistant && params.reasoningLevel === "on"
|
||||
const reasoningText = suppressAssistantArtifacts
|
||||
? ""
|
||||
: params.lastAssistant && params.reasoningLevel === "on"
|
||||
? formatReasoningMessage(extractAssistantThinking(params.lastAssistant))
|
||||
: "";
|
||||
if (reasoningText) {
|
||||
@@ -243,13 +248,14 @@ export function buildEmbeddedRunPayloads(params: {
|
||||
}
|
||||
return isRawApiErrorPayload(trimmed);
|
||||
};
|
||||
const answerTexts = (
|
||||
params.assistantTexts.length
|
||||
? params.assistantTexts
|
||||
: fallbackAnswerText
|
||||
? [fallbackAnswerText]
|
||||
: []
|
||||
).filter((text) => !shouldSuppressRawErrorText(text));
|
||||
const answerTexts = suppressAssistantArtifacts
|
||||
? []
|
||||
: (params.assistantTexts.length
|
||||
? params.assistantTexts
|
||||
: fallbackAnswerText
|
||||
? [fallbackAnswerText]
|
||||
: []
|
||||
).filter((text) => !shouldSuppressRawErrorText(text));
|
||||
|
||||
let hasUserFacingAssistantReply = false;
|
||||
for (const text of answerTexts) {
|
||||
|
||||
@@ -54,6 +54,7 @@ export type EmbeddedRunAttemptResult = {
|
||||
actionFingerprint?: string;
|
||||
};
|
||||
didSendViaMessagingTool: boolean;
|
||||
didSendDeterministicApprovalPrompt?: boolean;
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
|
||||
@@ -79,6 +79,36 @@ describe("runEmbeddedPiAgent usage reporting", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards memory flush write paths into memory-triggered attempts", async () => {
|
||||
mockedRunEmbeddedAttempt.mockResolvedValueOnce({
|
||||
aborted: false,
|
||||
promptError: null,
|
||||
timedOut: false,
|
||||
sessionIdUsed: "test-session",
|
||||
assistantTexts: [],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
await runEmbeddedPiAgent({
|
||||
sessionId: "test-session",
|
||||
sessionKey: "test-key",
|
||||
sessionFile: "/tmp/session.json",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
prompt: "flush",
|
||||
timeoutMs: 30000,
|
||||
runId: "run-memory-forwarding",
|
||||
trigger: "memory",
|
||||
memoryFlushWritePath: "memory/2026-03-10.md",
|
||||
});
|
||||
|
||||
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
trigger: "memory",
|
||||
memoryFlushWritePath: "memory/2026-03-10.md",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports total usage from the last turn instead of accumulated total", async () => {
|
||||
// Simulate a multi-turn run result.
|
||||
// Turn 1: Input 100, Output 50. Total 150.
|
||||
|
||||
@@ -54,8 +54,13 @@ describe("handleAgentEnd", () => {
|
||||
|
||||
const warn = vi.mocked(ctx.log.warn);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0]?.[0]).toContain("runId=run-1");
|
||||
expect(warn.mock.calls[0]?.[0]).toContain("error=connection refused");
|
||||
expect(warn.mock.calls[0]?.[0]).toBe("embedded run agent end");
|
||||
expect(warn.mock.calls[0]?.[1]).toMatchObject({
|
||||
event: "embedded_run_agent_end",
|
||||
runId: "run-1",
|
||||
error: "connection refused",
|
||||
rawErrorPreview: "connection refused",
|
||||
});
|
||||
expect(onAgentEvent).toHaveBeenCalledWith({
|
||||
stream: "lifecycle",
|
||||
data: {
|
||||
@@ -65,6 +70,59 @@ describe("handleAgentEnd", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches raw provider error metadata without changing the console message", () => {
|
||||
const ctx = createContext({
|
||||
role: "assistant",
|
||||
stopReason: "error",
|
||||
provider: "anthropic",
|
||||
model: "claude-test",
|
||||
errorMessage: '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}',
|
||||
content: [{ type: "text", text: "" }],
|
||||
});
|
||||
|
||||
handleAgentEnd(ctx);
|
||||
|
||||
const warn = vi.mocked(ctx.log.warn);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0]?.[0]).toBe("embedded run agent end");
|
||||
expect(warn.mock.calls[0]?.[1]).toMatchObject({
|
||||
event: "embedded_run_agent_end",
|
||||
runId: "run-1",
|
||||
error: "The AI service is temporarily overloaded. Please try again in a moment.",
|
||||
failoverReason: "overloaded",
|
||||
providerErrorType: "overloaded_error",
|
||||
});
|
||||
});
|
||||
|
||||
it("redacts logged error text before emitting lifecycle events", () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const ctx = createContext(
|
||||
{
|
||||
role: "assistant",
|
||||
stopReason: "error",
|
||||
errorMessage: "x-api-key: sk-abcdefghijklmnopqrstuvwxyz123456",
|
||||
content: [{ type: "text", text: "" }],
|
||||
},
|
||||
{ onAgentEvent },
|
||||
);
|
||||
|
||||
handleAgentEnd(ctx);
|
||||
|
||||
const warn = vi.mocked(ctx.log.warn);
|
||||
expect(warn.mock.calls[0]?.[1]).toMatchObject({
|
||||
event: "embedded_run_agent_end",
|
||||
error: "x-api-key: ***",
|
||||
rawErrorPreview: "x-api-key: ***",
|
||||
});
|
||||
expect(onAgentEvent).toHaveBeenCalledWith({
|
||||
stream: "lifecycle",
|
||||
data: {
|
||||
phase: "error",
|
||||
error: "x-api-key: ***",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps non-error run-end logging on debug only", () => {
|
||||
const ctx = createContext(undefined);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user