Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39073d5196 | ||
|
|
7725dd6795 | ||
|
|
db61451c67 | ||
|
|
9780748bbb | ||
|
|
f5cec1dd8b | ||
|
|
758f30eb7d | ||
|
|
7e1a17e5e6 | ||
|
|
0704fe7dbb | ||
|
|
d51eca64cc | ||
|
|
d0f9e22a4b |
No files matched your search
@@ -14,6 +14,7 @@ Docs: https://docs.clawd.bot
|
|||||||
## 2026.1.21
|
## 2026.1.21
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
|
||||||
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
||||||
- CLI: exec approvals mutations render tables instead of raw JSON.
|
- CLI: exec approvals mutations render tables instead of raw JSON.
|
||||||
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
||||||
@@ -29,6 +30,7 @@ Docs: https://docs.clawd.bot
|
|||||||
### Fixes
|
### Fixes
|
||||||
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
|
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
|
||||||
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
|
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
|
||||||
|
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x.
|
||||||
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
|
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
|
||||||
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
||||||
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
|
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
|
||||||
|
|||||||
@@ -280,8 +280,10 @@ enum ExecApprovalsStore {
|
|||||||
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
||||||
security: agentEntry.security ?? wildcardEntry.security ?? resolvedDefaults.security,
|
security: agentEntry.security ?? wildcardEntry.security ?? resolvedDefaults.security,
|
||||||
ask: agentEntry.ask ?? wildcardEntry.ask ?? resolvedDefaults.ask,
|
ask: agentEntry.ask ?? wildcardEntry.ask ?? resolvedDefaults.ask,
|
||||||
askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback ?? resolvedDefaults.askFallback,
|
askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback
|
||||||
autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills)
|
?? resolvedDefaults.askFallback,
|
||||||
|
autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills
|
||||||
|
?? resolvedDefaults.autoAllowSkills)
|
||||||
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
|
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
|
||||||
.map { entry in
|
.map { entry in
|
||||||
ExecAllowlistEntry(
|
ExecAllowlistEntry(
|
||||||
|
|||||||
@@ -191,7 +191,6 @@ struct GeneralSettings: View {
|
|||||||
if self.state.connectionMode == .remote {
|
if self.state.connectionMode == .remote {
|
||||||
self.remoteCard
|
self.remoteCard
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -480,62 +480,22 @@ actor MacNodeRuntime {
|
|||||||
message: "SYSTEM_RUN_DISABLED: security=deny")
|
message: "SYSTEM_RUN_DISABLED: security=deny")
|
||||||
}
|
}
|
||||||
|
|
||||||
let requiresAsk = ExecApprovalHelpers.requiresAsk(
|
let approval = await self.resolveSystemRunApproval(
|
||||||
ask: ask,
|
req: req,
|
||||||
security: security,
|
params: params,
|
||||||
allowlistMatch: allowlistMatch,
|
context: ExecRunContext(
|
||||||
skillAllow: skillAllow)
|
displayCommand: displayCommand,
|
||||||
|
security: security,
|
||||||
let decisionFromParams = ExecApprovalHelpers.parseDecision(params.approvalDecision)
|
ask: ask,
|
||||||
var approvedByAsk = params.approved == true || decisionFromParams != nil
|
agentId: agentId,
|
||||||
var persistAllowlist = decisionFromParams == .allowAlways
|
resolution: resolution,
|
||||||
if decisionFromParams == .deny {
|
allowlistMatch: allowlistMatch,
|
||||||
await self.emitExecEvent(
|
skillAllow: skillAllow,
|
||||||
"exec.denied",
|
sessionKey: sessionKey,
|
||||||
payload: ExecEventPayload(
|
runId: runId))
|
||||||
sessionKey: sessionKey,
|
if let response = approval.response { return response }
|
||||||
runId: runId,
|
let approvedByAsk = approval.approvedByAsk
|
||||||
host: "node",
|
let persistAllowlist = approval.persistAllowlist
|
||||||
command: displayCommand,
|
|
||||||
reason: "user-denied"))
|
|
||||||
return Self.errorResponse(
|
|
||||||
req,
|
|
||||||
code: .unavailable,
|
|
||||||
message: "SYSTEM_RUN_DENIED: user denied")
|
|
||||||
}
|
|
||||||
if requiresAsk, !approvedByAsk {
|
|
||||||
let decision = await MainActor.run {
|
|
||||||
ExecApprovalsPromptPresenter.prompt(
|
|
||||||
ExecApprovalPromptRequest(
|
|
||||||
command: displayCommand,
|
|
||||||
cwd: params.cwd,
|
|
||||||
host: "node",
|
|
||||||
security: security.rawValue,
|
|
||||||
ask: ask.rawValue,
|
|
||||||
agentId: agentId,
|
|
||||||
resolvedPath: resolution?.resolvedPath))
|
|
||||||
}
|
|
||||||
switch decision {
|
|
||||||
case .deny:
|
|
||||||
await self.emitExecEvent(
|
|
||||||
"exec.denied",
|
|
||||||
payload: ExecEventPayload(
|
|
||||||
sessionKey: sessionKey,
|
|
||||||
runId: runId,
|
|
||||||
host: "node",
|
|
||||||
command: displayCommand,
|
|
||||||
reason: "user-denied"))
|
|
||||||
return Self.errorResponse(
|
|
||||||
req,
|
|
||||||
code: .unavailable,
|
|
||||||
message: "SYSTEM_RUN_DENIED: user denied")
|
|
||||||
case .allowAlways:
|
|
||||||
approvedByAsk = true
|
|
||||||
persistAllowlist = true
|
|
||||||
case .allowOnce:
|
|
||||||
approvedByAsk = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if persistAllowlist, security == .allowlist,
|
if persistAllowlist, security == .allowlist,
|
||||||
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution)
|
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution)
|
||||||
{
|
{
|
||||||
@@ -659,6 +619,99 @@ actor MacNodeRuntime {
|
|||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct ExecApprovalOutcome {
|
||||||
|
var approvedByAsk: Bool
|
||||||
|
var persistAllowlist: Bool
|
||||||
|
var response: BridgeInvokeResponse?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ExecRunContext {
|
||||||
|
var displayCommand: String
|
||||||
|
var security: ExecSecurity
|
||||||
|
var ask: ExecAsk
|
||||||
|
var agentId: String?
|
||||||
|
var resolution: ExecCommandResolution?
|
||||||
|
var allowlistMatch: ExecAllowlistEntry?
|
||||||
|
var skillAllow: Bool
|
||||||
|
var sessionKey: String
|
||||||
|
var runId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveSystemRunApproval(
|
||||||
|
req: BridgeInvokeRequest,
|
||||||
|
params: ClawdbotSystemRunParams,
|
||||||
|
context: ExecRunContext) async -> ExecApprovalOutcome
|
||||||
|
{
|
||||||
|
let requiresAsk = ExecApprovalHelpers.requiresAsk(
|
||||||
|
ask: context.ask,
|
||||||
|
security: context.security,
|
||||||
|
allowlistMatch: context.allowlistMatch,
|
||||||
|
skillAllow: context.skillAllow)
|
||||||
|
|
||||||
|
let decisionFromParams = ExecApprovalHelpers.parseDecision(params.approvalDecision)
|
||||||
|
var approvedByAsk = params.approved == true || decisionFromParams != nil
|
||||||
|
var persistAllowlist = decisionFromParams == .allowAlways
|
||||||
|
if decisionFromParams == .deny {
|
||||||
|
await self.emitExecEvent(
|
||||||
|
"exec.denied",
|
||||||
|
payload: ExecEventPayload(
|
||||||
|
sessionKey: context.sessionKey,
|
||||||
|
runId: context.runId,
|
||||||
|
host: "node",
|
||||||
|
command: context.displayCommand,
|
||||||
|
reason: "user-denied"))
|
||||||
|
return ExecApprovalOutcome(
|
||||||
|
approvedByAsk: approvedByAsk,
|
||||||
|
persistAllowlist: persistAllowlist,
|
||||||
|
response: Self.errorResponse(
|
||||||
|
req,
|
||||||
|
code: .unavailable,
|
||||||
|
message: "SYSTEM_RUN_DENIED: user denied"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if requiresAsk, !approvedByAsk {
|
||||||
|
let decision = await MainActor.run {
|
||||||
|
ExecApprovalsPromptPresenter.prompt(
|
||||||
|
ExecApprovalPromptRequest(
|
||||||
|
command: context.displayCommand,
|
||||||
|
cwd: params.cwd,
|
||||||
|
host: "node",
|
||||||
|
security: context.security.rawValue,
|
||||||
|
ask: context.ask.rawValue,
|
||||||
|
agentId: context.agentId,
|
||||||
|
resolvedPath: context.resolution?.resolvedPath))
|
||||||
|
}
|
||||||
|
switch decision {
|
||||||
|
case .deny:
|
||||||
|
await self.emitExecEvent(
|
||||||
|
"exec.denied",
|
||||||
|
payload: ExecEventPayload(
|
||||||
|
sessionKey: context.sessionKey,
|
||||||
|
runId: context.runId,
|
||||||
|
host: "node",
|
||||||
|
command: context.displayCommand,
|
||||||
|
reason: "user-denied"))
|
||||||
|
return ExecApprovalOutcome(
|
||||||
|
approvedByAsk: approvedByAsk,
|
||||||
|
persistAllowlist: persistAllowlist,
|
||||||
|
response: Self.errorResponse(
|
||||||
|
req,
|
||||||
|
code: .unavailable,
|
||||||
|
message: "SYSTEM_RUN_DENIED: user denied"))
|
||||||
|
case .allowAlways:
|
||||||
|
approvedByAsk = true
|
||||||
|
persistAllowlist = true
|
||||||
|
case .allowOnce:
|
||||||
|
approvedByAsk = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExecApprovalOutcome(
|
||||||
|
approvedByAsk: approvedByAsk,
|
||||||
|
persistAllowlist: persistAllowlist,
|
||||||
|
response: nil)
|
||||||
|
}
|
||||||
|
|
||||||
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
_ = ExecApprovalsStore.ensureFile()
|
_ = ExecApprovalsStore.ensureFile()
|
||||||
let snapshot = ExecApprovalsStore.readSnapshot()
|
let snapshot = ExecApprovalsStore.readSnapshot()
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ struct PermissionStatusList: View {
|
|||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.padding(.top, 2)
|
.padding(.top, 2)
|
||||||
.help("Refresh status")
|
.help("Refresh status")
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -221,6 +221,6 @@ final class TailscaleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func fallbackTailnetIPv4() -> String? {
|
nonisolated static func fallbackTailnetIPv4() -> String? {
|
||||||
Self.detectTailnetIPv4()
|
self.detectTailnetIPv4()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1908,6 +1908,7 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct ExecApprovalRequestParams: Codable, Sendable {
|
public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||||
|
public let id: String?
|
||||||
public let command: String
|
public let command: String
|
||||||
public let cwd: String?
|
public let cwd: String?
|
||||||
public let host: String?
|
public let host: String?
|
||||||
@@ -1919,6 +1920,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
|||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
|
id: String?,
|
||||||
command: String,
|
command: String,
|
||||||
cwd: String?,
|
cwd: String?,
|
||||||
host: String?,
|
host: String?,
|
||||||
@@ -1929,6 +1931,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
|||||||
sessionkey: String?,
|
sessionkey: String?,
|
||||||
timeoutms: Int?
|
timeoutms: Int?
|
||||||
) {
|
) {
|
||||||
|
self.id = id
|
||||||
self.command = command
|
self.command = command
|
||||||
self.cwd = cwd
|
self.cwd = cwd
|
||||||
self.host = host
|
self.host = host
|
||||||
@@ -1940,6 +1943,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
|||||||
self.timeoutms = timeoutms
|
self.timeoutms = timeoutms
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
case command
|
case command
|
||||||
case cwd
|
case cwd
|
||||||
case host
|
case host
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ import Testing
|
|||||||
|
|
||||||
@Test func expectedGatewayVersionFromStringUsesParser() {
|
@Test func expectedGatewayVersionFromStringUsesParser() {
|
||||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
|
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
|
||||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11))
|
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(
|
||||||
|
major: 2026,
|
||||||
|
minor: 1,
|
||||||
|
patch: 11))
|
||||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil)
|
#expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1908,6 +1908,7 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct ExecApprovalRequestParams: Codable, Sendable {
|
public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||||
|
public let id: String?
|
||||||
public let command: String
|
public let command: String
|
||||||
public let cwd: String?
|
public let cwd: String?
|
||||||
public let host: String?
|
public let host: String?
|
||||||
@@ -1919,6 +1920,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
|||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
|
id: String?,
|
||||||
command: String,
|
command: String,
|
||||||
cwd: String?,
|
cwd: String?,
|
||||||
host: String?,
|
host: String?,
|
||||||
@@ -1929,6 +1931,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
|||||||
sessionkey: String?,
|
sessionkey: String?,
|
||||||
timeoutms: Int?
|
timeoutms: Int?
|
||||||
) {
|
) {
|
||||||
|
self.id = id
|
||||||
self.command = command
|
self.command = command
|
||||||
self.cwd = cwd
|
self.cwd = cwd
|
||||||
self.host = host
|
self.host = host
|
||||||
@@ -1940,6 +1943,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
|||||||
self.timeoutms = timeoutms
|
self.timeoutms = timeoutms
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
case command
|
case command
|
||||||
case cwd
|
case cwd
|
||||||
case host
|
case host
|
||||||
|
|||||||
@@ -334,6 +334,7 @@ WhatsApp sends audio as **voice notes** (PTT bubble).
|
|||||||
- `agents.defaults.heartbeat.model` (optional override)
|
- `agents.defaults.heartbeat.model` (optional override)
|
||||||
- `agents.defaults.heartbeat.target`
|
- `agents.defaults.heartbeat.target`
|
||||||
- `agents.defaults.heartbeat.to`
|
- `agents.defaults.heartbeat.to`
|
||||||
|
- `agents.defaults.heartbeat.session`
|
||||||
- `agents.list[].heartbeat.*` (per-agent overrides)
|
- `agents.list[].heartbeat.*` (per-agent overrides)
|
||||||
- `session.*` (scope, idle, store, mainKey)
|
- `session.*` (scope, idle, store, mainKey)
|
||||||
- `web.enabled` (disable channel startup when false)
|
- `web.enabled` (disable channel startup when false)
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ Exact allowlist is enforced in `src/gateway/server-bridge.ts`.
|
|||||||
|
|
||||||
## Exec lifecycle events
|
## Exec lifecycle events
|
||||||
|
|
||||||
Nodes can emit `exec.started`, `exec.finished`, or `exec.denied` events to surface
|
Nodes can emit `exec.finished` or `exec.denied` events to surface system.run activity.
|
||||||
system.run activity. These are mapped to system events in the gateway.
|
These are mapped to system events in the gateway. (Legacy nodes may still emit `exec.started`.)
|
||||||
|
|
||||||
Payload fields (all optional unless noted):
|
Payload fields (all optional unless noted):
|
||||||
- `sessionKey` (required): agent session to receive the system event.
|
- `sessionKey` (required): agent session to receive the system event.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Unknown keys, malformed types, or invalid values cause the Gateway to **refuse t
|
|||||||
|
|
||||||
When validation fails:
|
When validation fails:
|
||||||
- The Gateway does not boot.
|
- The Gateway does not boot.
|
||||||
- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot gateway status`, `clawdbot gateway probe`, `clawdbot help`).
|
- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot service`, `clawdbot help`).
|
||||||
- Run `clawdbot doctor` to see the exact issues.
|
- Run `clawdbot doctor` to see the exact issues.
|
||||||
- Run `clawdbot doctor --fix` (or `--yes`) to apply migrations/repairs.
|
- Run `clawdbot doctor --fix` (or `--yes`) to apply migrations/repairs.
|
||||||
|
|
||||||
@@ -1414,7 +1414,7 @@ Each `agents.defaults.models` entry can include:
|
|||||||
- `alias` (optional model shortcut, e.g. `/opus`).
|
- `alias` (optional model shortcut, e.g. `/opus`).
|
||||||
- `params` (optional provider-specific API params passed through to the model request).
|
- `params` (optional provider-specific API params passed through to the model request).
|
||||||
|
|
||||||
`params` is also applied to streaming runs (embedded agent + compaction). Supported keys today: `temperature`, `maxTokens`, `cacheControlTtl` (`"5m"` or `"1h"`, Anthropic API + OpenRouter Anthropic models only; ignored for Anthropic OAuth/Claude Code tokens). These merge with call-time options; caller-supplied values win. `temperature` is an advanced knob—leave unset unless you know the model’s defaults and need a change. Clawdbot includes the `extended-cache-ttl-2025-04-11` beta flag for Anthropic API; keep it if you override provider headers.
|
`params` is also applied to streaming runs (embedded agent + compaction). Supported keys today: `temperature`, `maxTokens`. These merge with call-time options; caller-supplied values win. `temperature` is an advanced knob—leave unset unless you know the model’s defaults and need a change.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@@ -1569,7 +1569,7 @@ Example:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `agents.defaults.contextPruning` (TTL-aware tool-result pruning)
|
#### `agents.defaults.contextPruning` (tool-result pruning)
|
||||||
|
|
||||||
`agents.defaults.contextPruning` prunes **old tool results** from the in-memory context right before a request is sent to the LLM.
|
`agents.defaults.contextPruning` prunes **old tool results** from the in-memory context right before a request is sent to the LLM.
|
||||||
It does **not** modify the session history on disk (`*.jsonl` remains complete).
|
It does **not** modify the session history on disk (`*.jsonl` remains complete).
|
||||||
@@ -1580,9 +1580,11 @@ High level:
|
|||||||
- Never touches user/assistant messages.
|
- Never touches user/assistant messages.
|
||||||
- Protects the last `keepLastAssistants` assistant messages (no tool results after that point are pruned).
|
- Protects the last `keepLastAssistants` assistant messages (no tool results after that point are pruned).
|
||||||
- Protects the bootstrap prefix (nothing before the first user message is pruned).
|
- Protects the bootstrap prefix (nothing before the first user message is pruned).
|
||||||
- Mode:
|
- Modes:
|
||||||
- `cache-ttl`: pruning only runs when the last Anthropic call for the session is **older** than `ttl`.
|
- `adaptive`: soft-trims oversized tool results (keep head/tail) when the estimated context ratio crosses `softTrimRatio`.
|
||||||
When it runs, it uses the same soft-trim + hard-clear behavior as before.
|
Then hard-clears the oldest eligible tool results when the estimated context ratio crosses `hardClearRatio` **and**
|
||||||
|
there’s enough prunable tool-result bulk (`minPrunableToolChars`).
|
||||||
|
- `aggressive`: always replaces eligible tool results before the cutoff with the `hardClear.placeholder` (no ratio checks).
|
||||||
|
|
||||||
Soft vs hard pruning (what changes in the context sent to the LLM):
|
Soft vs hard pruning (what changes in the context sent to the LLM):
|
||||||
- **Soft-trim**: only for *oversized* tool results. Keeps the beginning + end and inserts `...` in the middle.
|
- **Soft-trim**: only for *oversized* tool results. Keeps the beginning + end and inserts `...` in the middle.
|
||||||
@@ -1596,41 +1598,44 @@ Notes / current limitations:
|
|||||||
- Tool results containing **image blocks are skipped** (never trimmed/cleared) right now.
|
- Tool results containing **image blocks are skipped** (never trimmed/cleared) right now.
|
||||||
- The estimated “context ratio” is based on **characters** (approximate), not exact tokens.
|
- The estimated “context ratio” is based on **characters** (approximate), not exact tokens.
|
||||||
- If the session doesn’t contain at least `keepLastAssistants` assistant messages yet, pruning is skipped.
|
- If the session doesn’t contain at least `keepLastAssistants` assistant messages yet, pruning is skipped.
|
||||||
- `cache-ttl` only activates for Anthropic API calls (and OpenRouter Anthropic models).
|
- In `aggressive` mode, `hardClear.enabled` is ignored (eligible tool results are always replaced with `hardClear.placeholder`).
|
||||||
- After a prune, the TTL window resets so subsequent requests keep cache until `ttl` expires again.
|
|
||||||
- For best results, match `contextPruning.ttl` to the model `cacheControlTtl` you set in `agents.defaults.models.*.params`.
|
|
||||||
|
|
||||||
Default (off, unless Anthropic auth profiles are detected):
|
Default (adaptive):
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agents: { defaults: { contextPruning: { mode: "adaptive" } } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To disable:
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
agents: { defaults: { contextPruning: { mode: "off" } } }
|
agents: { defaults: { contextPruning: { mode: "off" } } }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Enable TTL-aware pruning:
|
Defaults (when `mode` is `"adaptive"` or `"aggressive"`):
|
||||||
|
- `keepLastAssistants`: `3`
|
||||||
|
- `softTrimRatio`: `0.3` (adaptive only)
|
||||||
|
- `hardClearRatio`: `0.5` (adaptive only)
|
||||||
|
- `minPrunableToolChars`: `50000` (adaptive only)
|
||||||
|
- `softTrim`: `{ maxChars: 4000, headChars: 1500, tailChars: 1500 }` (adaptive only)
|
||||||
|
- `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }`
|
||||||
|
|
||||||
|
Example (aggressive, minimal):
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
agents: { defaults: { contextPruning: { mode: "cache-ttl" } } }
|
agents: { defaults: { contextPruning: { mode: "aggressive" } } }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Defaults (when `mode` is `"cache-ttl"`):
|
Example (adaptive tuned):
|
||||||
- `ttl`: `"5m"`
|
|
||||||
- `keepLastAssistants`: `3`
|
|
||||||
- `softTrimRatio`: `0.3`
|
|
||||||
- `hardClearRatio`: `0.5`
|
|
||||||
- `minPrunableToolChars`: `50000`
|
|
||||||
- `softTrim`: `{ maxChars: 4000, headChars: 1500, tailChars: 1500 }`
|
|
||||||
- `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }`
|
|
||||||
|
|
||||||
Example (cache-ttl tuned):
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
contextPruning: {
|
contextPruning: {
|
||||||
mode: "cache-ttl",
|
mode: "adaptive",
|
||||||
ttl: "5m",
|
|
||||||
keepLastAssistants: 3,
|
keepLastAssistants: 3,
|
||||||
softTrimRatio: 0.3,
|
softTrimRatio: 0.3,
|
||||||
hardClearRatio: 0.5,
|
hardClearRatio: 0.5,
|
||||||
@@ -1737,12 +1742,9 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
|
|||||||
`30m`. Set `0m` to disable.
|
`30m`. Set `0m` to disable.
|
||||||
- `model`: optional override model for heartbeat runs (`provider/model`).
|
- `model`: optional override model for heartbeat runs (`provider/model`).
|
||||||
- `includeReasoning`: when `true`, heartbeats will also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). Default: `false`.
|
- `includeReasoning`: when `true`, heartbeats will also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). Default: `false`.
|
||||||
- `activeHours`: optional local-time window that controls when heartbeats run.
|
- `session`: optional session key to control which session the heartbeat runs in. Default: `main`.
|
||||||
- `start`: start time (HH:MM, 24h). Inclusive.
|
|
||||||
- `end`: end time (HH:MM, 24h). Exclusive. Use `"24:00"` for end-of-day.
|
|
||||||
- `timezone`: `"user"` (default), `"local"`, or an IANA timezone id.
|
|
||||||
- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`.
|
|
||||||
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram).
|
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram).
|
||||||
|
- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `msteams`, `signal`, `imessage`, `none`). Default: `last`.
|
||||||
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md` line if you still want the file read.
|
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md` line if you still want the file read.
|
||||||
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 300).
|
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 300).
|
||||||
|
|
||||||
@@ -1773,7 +1775,6 @@ Note: `applyPatch` is only under `tools.exec`.
|
|||||||
- `tools.web.fetch.maxChars` (default 50000)
|
- `tools.web.fetch.maxChars` (default 50000)
|
||||||
- `tools.web.fetch.timeoutSeconds` (default 30)
|
- `tools.web.fetch.timeoutSeconds` (default 30)
|
||||||
- `tools.web.fetch.cacheTtlMinutes` (default 15)
|
- `tools.web.fetch.cacheTtlMinutes` (default 15)
|
||||||
- `tools.web.fetch.maxRedirects` (default 3)
|
|
||||||
- `tools.web.fetch.userAgent` (optional override)
|
- `tools.web.fetch.userAgent` (optional override)
|
||||||
- `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only)
|
- `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only)
|
||||||
- `tools.web.fetch.firecrawl.enabled` (default true when an API key is set)
|
- `tools.web.fetch.firecrawl.enabled` (default true when an API key is set)
|
||||||
@@ -1840,7 +1841,7 @@ Example:
|
|||||||
|
|
||||||
`agents.defaults.subagents` configures sub-agent defaults:
|
`agents.defaults.subagents` configures sub-agent defaults:
|
||||||
- `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call.
|
- `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call.
|
||||||
- `maxConcurrent`: max concurrent sub-agent runs (default 8)
|
- `maxConcurrent`: max concurrent sub-agent runs (default 1)
|
||||||
- `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable)
|
- `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable)
|
||||||
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins)
|
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins)
|
||||||
|
|
||||||
@@ -1974,7 +1975,7 @@ Notes:
|
|||||||
|
|
||||||
`agents.defaults.maxConcurrent` sets the maximum number of embedded agent runs that can
|
`agents.defaults.maxConcurrent` sets the maximum number of embedded agent runs that can
|
||||||
execute in parallel across sessions. Each session is still serialized (one run
|
execute in parallel across sessions. Each session is still serialized (one run
|
||||||
per session key at a time). Default: 4.
|
per session key at a time). Default: 1.
|
||||||
|
|
||||||
### `agents.defaults.sandbox`
|
### `agents.defaults.sandbox`
|
||||||
|
|
||||||
@@ -2452,9 +2453,6 @@ Controls session scoping, reset policy, reset triggers, and where the session st
|
|||||||
dm: { mode: "idle", idleMinutes: 240 },
|
dm: { mode: "idle", idleMinutes: 240 },
|
||||||
group: { mode: "idle", idleMinutes: 120 }
|
group: { mode: "idle", idleMinutes: 120 }
|
||||||
},
|
},
|
||||||
resetByChannel: {
|
|
||||||
discord: { mode: "idle", idleMinutes: 10080 }
|
|
||||||
},
|
|
||||||
resetTriggers: ["/new", "/reset"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
|
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
|
||||||
// You can override with {agentId} templating:
|
// You can override with {agentId} templating:
|
||||||
@@ -2490,7 +2488,7 @@ Fields:
|
|||||||
- `idleMinutes`: sliding idle window in minutes. When daily + idle are both configured, whichever expires first wins.
|
- `idleMinutes`: sliding idle window in minutes. When daily + idle are both configured, whichever expires first wins.
|
||||||
- `resetByType`: per-session overrides for `dm`, `group`, and `thread`.
|
- `resetByType`: per-session overrides for `dm`, `group`, and `thread`.
|
||||||
- If you only set legacy `session.idleMinutes` without any `reset`/`resetByType`, Clawdbot stays in idle-only mode for backward compatibility.
|
- If you only set legacy `session.idleMinutes` without any `reset`/`resetByType`, Clawdbot stays in idle-only mode for backward compatibility.
|
||||||
- `resetByChannel`: channel-specific reset policy overrides (keyed by channel id, applies to all session types for that channel; overrides `reset`/`resetByType`).
|
- `heartbeatIdleMinutes`: optional idle override for heartbeat checks (daily reset still applies when enabled).
|
||||||
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
|
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
|
||||||
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
|
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
|
||||||
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
|
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
|
||||||
@@ -2617,13 +2615,10 @@ Defaults:
|
|||||||
// noSandbox: false,
|
// noSandbox: false,
|
||||||
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||||
// attachOnly: false, // set true when tunneling a remote CDP to localhost
|
// attachOnly: false, // set true when tunneling a remote CDP to localhost
|
||||||
// snapshotDefaults: { mode: "efficient" }, // tool/CLI default snapshot preset
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: `browser.snapshotDefaults` only affects Clawdbot's browser tool + CLI. Direct HTTP clients must pass `mode` explicitly.
|
|
||||||
|
|
||||||
### `ui` (Appearance)
|
### `ui` (Appearance)
|
||||||
|
|
||||||
Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint).
|
Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint).
|
||||||
@@ -2647,13 +2642,6 @@ Defaults:
|
|||||||
- bind: `loopback`
|
- bind: `loopback`
|
||||||
- port: `18789` (single port for WS + HTTP)
|
- port: `18789` (single port for WS + HTTP)
|
||||||
|
|
||||||
Bind modes:
|
|
||||||
- `loopback`: `127.0.0.1` (local-only)
|
|
||||||
- `lan`: `0.0.0.0` (all interfaces)
|
|
||||||
- `tailnet`: Tailscale IPv4 address (100.64.0.0/10)
|
|
||||||
- `auto`: prefer loopback, fall back to LAN if loopback cannot bind
|
|
||||||
- `custom`: `gateway.customBindHost` (IPv4), fallback to LAN if unavailable
|
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
gateway: {
|
gateway: {
|
||||||
@@ -2684,15 +2672,14 @@ Notes:
|
|||||||
- `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
|
- `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
|
||||||
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).
|
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).
|
||||||
- OpenAI Chat Completions endpoint: **disabled by default**; enable with `gateway.http.endpoints.chatCompletions.enabled: true`.
|
- OpenAI Chat Completions endpoint: **disabled by default**; enable with `gateway.http.endpoints.chatCompletions.enabled: true`.
|
||||||
- OpenResponses endpoint: **disabled by default**; enable with `gateway.http.endpoints.responses.enabled: true`.
|
|
||||||
- Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.
|
- Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.
|
||||||
- Non-loopback binds (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
|
- Non-loopback binds (`lan`/`tailnet`/`auto`) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
|
||||||
- The onboarding wizard generates a gateway token by default (even on loopback).
|
- The onboarding wizard generates a gateway token by default (even on loopback).
|
||||||
- `gateway.remote.token` is **only** for remote CLI calls; it does not enable local gateway auth. `gateway.token` is ignored.
|
- `gateway.remote.token` is **only** for remote CLI calls; it does not enable local gateway auth. `gateway.token` is ignored.
|
||||||
|
|
||||||
Auth and Tailscale:
|
Auth and Tailscale:
|
||||||
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`).
|
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`).
|
||||||
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine and as the bootstrap credential for device pairing).
|
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine).
|
||||||
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
|
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
|
||||||
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
|
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
|
||||||
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
|
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
|
||||||
@@ -2701,9 +2688,6 @@ Auth and Tailscale:
|
|||||||
`true`, Serve requests do not need a token/password; set `false` to require
|
`true`, Serve requests do not need a token/password; set `false` to require
|
||||||
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and
|
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and
|
||||||
auth mode is not `password`.
|
auth mode is not `password`.
|
||||||
- After pairing, the Gateway issues **device tokens** scoped to the device role + scopes.
|
|
||||||
These are returned in `hello-ok.auth.deviceToken`; clients should persist and reuse them
|
|
||||||
instead of the shared token. Rotate/revoke via `device.token.rotate`/`device.token.revoke`.
|
|
||||||
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
|
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
|
||||||
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
|
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
|
||||||
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
|
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
|
||||||
@@ -2712,7 +2696,6 @@ Remote client defaults (CLI):
|
|||||||
- `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`.
|
- `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`.
|
||||||
- `gateway.remote.token` supplies the token for remote calls (leave unset for no auth).
|
- `gateway.remote.token` supplies the token for remote calls (leave unset for no auth).
|
||||||
- `gateway.remote.password` supplies the password for remote calls (leave unset for no auth).
|
- `gateway.remote.password` supplies the password for remote calls (leave unset for no auth).
|
||||||
- `gateway.remote.tlsFingerprint` pins the gateway TLS cert fingerprint (sha256).
|
|
||||||
|
|
||||||
macOS app behavior:
|
macOS app behavior:
|
||||||
- Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
|
- Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
|
||||||
@@ -2726,36 +2709,12 @@ macOS app behavior:
|
|||||||
remote: {
|
remote: {
|
||||||
url: "ws://gateway.tailnet:18789",
|
url: "ws://gateway.tailnet:18789",
|
||||||
token: "your-token",
|
token: "your-token",
|
||||||
password: "your-password",
|
password: "your-password"
|
||||||
tlsFingerprint: "sha256:ab12cd34..."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `gateway.nodes` (Node command allowlist)
|
|
||||||
|
|
||||||
The Gateway enforces a per-platform command allowlist for `node.invoke`. Nodes must both
|
|
||||||
**declare** a command and have it **allowed** by the Gateway to run it.
|
|
||||||
|
|
||||||
Use this section to extend or deny commands:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
gateway: {
|
|
||||||
nodes: {
|
|
||||||
allowCommands: ["custom.vendor.command"], // extra commands beyond defaults
|
|
||||||
denyCommands: ["sms.send"] // block a command even if declared
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `allowCommands` extends the built-in per-platform defaults.
|
|
||||||
- `denyCommands` always wins (even if the node claims the command).
|
|
||||||
- `node.invoke` rejects commands that are not declared by the node.
|
|
||||||
|
|
||||||
### `gateway.reload` (Config hot reload)
|
### `gateway.reload` (Config hot reload)
|
||||||
|
|
||||||
The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically.
|
The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically.
|
||||||
@@ -3003,7 +2962,7 @@ Auto-generated certs require `openssl` on PATH; if generation fails, the bridge
|
|||||||
|
|
||||||
### `discovery.wideArea` (Wide-Area Bonjour / unicast DNS‑SD)
|
### `discovery.wideArea` (Wide-Area Bonjour / unicast DNS‑SD)
|
||||||
|
|
||||||
When enabled, the Gateway writes a unicast DNS-SD zone for `_clawdbot-gw._tcp` under `~/.clawdbot/dns/` using the standard discovery domain `clawdbot.internal.`
|
When enabled, the Gateway writes a unicast DNS-SD zone for `_clawdbot-bridge._tcp` under `~/.clawdbot/dns/` using the standard discovery domain `clawdbot.internal.`
|
||||||
|
|
||||||
To make iOS/Android discover across networks (Vienna ⇄ London), pair this with:
|
To make iOS/Android discover across networks (Vienna ⇄ London), pair this with:
|
||||||
- a DNS server on the gateway host serving `clawdbot.internal.` (CoreDNS is recommended)
|
- a DNS server on the gateway host serving `clawdbot.internal.` (CoreDNS is recommended)
|
||||||
@@ -3033,9 +2992,6 @@ Template placeholders are expanded in `tools.media.*.models[].args` and `tools.m
|
|||||||
| `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per channel) |
|
| `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per channel) |
|
||||||
| `{{To}}` | Destination identifier |
|
| `{{To}}` | Destination identifier |
|
||||||
| `{{MessageSid}}` | Channel message id (when available) |
|
| `{{MessageSid}}` | Channel message id (when available) |
|
||||||
| `{{MessageSidFull}}` | Provider-specific full message id when `MessageSid` is shortened |
|
|
||||||
| `{{ReplyToId}}` | Reply-to message id (when available) |
|
|
||||||
| `{{ReplyToIdFull}}` | Provider-specific full reply-to id when `ReplyToId` is shortened |
|
|
||||||
| `{{SessionId}}` | Current session UUID |
|
| `{{SessionId}}` | Current session UUID |
|
||||||
| `{{IsNewSession}}` | `"true"` when a new session was created |
|
| `{{IsNewSession}}` | `"true"` when a new session was created |
|
||||||
| `{{MediaUrl}}` | Inbound media pseudo-URL (if present) |
|
| `{{MediaUrl}}` | Inbound media pseudo-URL (if present) |
|
||||||
|
|||||||
@@ -127,18 +127,26 @@ Example: two agents, only the second agent runs heartbeats.
|
|||||||
- `every`: heartbeat interval (duration string; default unit = minutes).
|
- `every`: heartbeat interval (duration string; default unit = minutes).
|
||||||
- `model`: optional model override for heartbeat runs (`provider/model`).
|
- `model`: optional model override for heartbeat runs (`provider/model`).
|
||||||
- `includeReasoning`: when enabled, also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`).
|
- `includeReasoning`: when enabled, also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`).
|
||||||
|
- `session`: optional session key for heartbeat runs.
|
||||||
|
- `main` (default): agent main session.
|
||||||
|
- Explicit session key (copy from `clawdbot sessions --json` or the [sessions CLI](/cli/sessions)).
|
||||||
|
- Session key formats: see [Sessions](/concepts/session) and [Groups](/concepts/groups).
|
||||||
- `target`:
|
- `target`:
|
||||||
- `last` (default): deliver to the last used external channel.
|
- `last` (default): deliver to the last used external channel.
|
||||||
- explicit channel: `whatsapp` / `telegram` / `discord` / `slack` / `signal` / `imessage`.
|
- explicit channel: `whatsapp` / `telegram` / `discord` / `slack` / `msteams` / `signal` / `imessage`.
|
||||||
- `none`: run the heartbeat but **do not deliver** externally.
|
- `none`: run the heartbeat but **do not deliver** externally.
|
||||||
- `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram, etc.).
|
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id).
|
||||||
- `prompt`: overrides the default prompt body (not merged).
|
- `prompt`: overrides the default prompt body (not merged).
|
||||||
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery.
|
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery.
|
||||||
|
|
||||||
## Delivery behavior
|
## Delivery behavior
|
||||||
|
|
||||||
- Heartbeats run in each agent’s **main session** (`agent:<id>:<mainKey>`), or `global`
|
- Heartbeats run in the agent’s main session by default (`agent:<id>:<mainKey>`),
|
||||||
when `session.scope = "global"`.
|
or `global` when `session.scope = "global"`. Set `session` to override to a
|
||||||
|
specific channel session (Discord/WhatsApp/etc.).
|
||||||
|
- `session` only affects the run context; delivery is controlled by `target` and `to`.
|
||||||
|
- To deliver to a specific channel/recipient, set `target` + `to`. With
|
||||||
|
`target: "last"`, delivery uses the last external channel for that session.
|
||||||
- If the main queue is busy, the heartbeat is skipped and retried later.
|
- If the main queue is busy, the heartbeat is skipped and retried later.
|
||||||
- If `target` resolves to no external destination, the run still happens but no
|
- If `target` resolves to no external destination, the run still happens but no
|
||||||
outbound message is sent.
|
outbound message is sent.
|
||||||
|
|||||||
@@ -134,6 +134,10 @@ When a prompt is required, the gateway broadcasts `exec.approval.requested` to o
|
|||||||
The Control UI and macOS app resolve it via `exec.approval.resolve`, then the gateway forwards the
|
The Control UI and macOS app resolve it via `exec.approval.resolve`, then the gateway forwards the
|
||||||
approved request to the node host.
|
approved request to the node host.
|
||||||
|
|
||||||
|
When approvals are required, the exec tool returns immediately with an approval id. Use that id to
|
||||||
|
correlate later system events (`Exec finished` / `Exec denied`). If no decision arrives before the
|
||||||
|
timeout, the request is treated as an approval timeout and surfaced as a denial reason.
|
||||||
|
|
||||||
The confirmation dialog includes:
|
The confirmation dialog includes:
|
||||||
- command + args
|
- command + args
|
||||||
- cwd
|
- cwd
|
||||||
@@ -162,11 +166,13 @@ Security notes:
|
|||||||
## System events
|
## System events
|
||||||
|
|
||||||
Exec lifecycle is surfaced as system messages:
|
Exec lifecycle is surfaced as system messages:
|
||||||
- `exec.started`
|
- `Exec running` (only if the command exceeds the running notice threshold)
|
||||||
- `exec.finished`
|
- `Exec finished`
|
||||||
- `exec.denied`
|
- `Exec denied`
|
||||||
|
|
||||||
These are posted to the agent’s session after the node reports the event.
|
These are posted to the agent’s session after the node reports the event.
|
||||||
|
Gateway-host exec approvals emit the same lifecycle events when the command finishes (and optionally when running longer than the threshold).
|
||||||
|
Approval-gated execs reuse the approval id as the `runId` in these messages for easy correlation.
|
||||||
|
|
||||||
## Implications
|
## Implications
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ Notes:
|
|||||||
## Config
|
## Config
|
||||||
|
|
||||||
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
|
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
|
||||||
|
- `tools.exec.approvalRunningNoticeMs` (default: 10000): emit a single “running” notice when an approval-gated exec runs longer than this (0 disables).
|
||||||
- `tools.exec.host` (default: `sandbox`)
|
- `tools.exec.host` (default: `sandbox`)
|
||||||
- `tools.exec.security` (default: `deny` for sandbox, `allowlist` for gateway + node when unset)
|
- `tools.exec.security` (default: `deny` for sandbox, `allowlist` for gateway + node when unset)
|
||||||
- `tools.exec.ask` (default: `on-miss`)
|
- `tools.exec.ask` (default: `on-miss`)
|
||||||
@@ -92,6 +93,11 @@ Example:
|
|||||||
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
|
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
|
||||||
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
|
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
|
||||||
|
|
||||||
|
When approvals are required, the exec tool returns immediately with
|
||||||
|
`status: "approval-pending"` and an approval id. Once approved (or denied / timed out),
|
||||||
|
the Gateway emits system events (`Exec finished` / `Exec denied`). If the command is still
|
||||||
|
running after `tools.exec.approvalRunningNoticeMs`, a single `Exec running` notice is emitted.
|
||||||
|
|
||||||
## Allowlist + safe bins
|
## Allowlist + safe bins
|
||||||
|
|
||||||
Allowlist enforcement matches **resolved binary paths only** (no basename matches). When
|
Allowlist enforcement matches **resolved binary paths only** (no basename matches). When
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("./tools/gateway.js", () => ({
|
||||||
|
callGatewayTool: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./tools/nodes-utils.js", () => ({
|
||||||
|
listNodes: vi.fn(async () => [
|
||||||
|
{ nodeId: "node-1", commands: ["system.run"], platform: "darwin" },
|
||||||
|
]),
|
||||||
|
resolveNodeIdFromList: vi.fn((nodes: Array<{ nodeId: string }>) => nodes[0]?.nodeId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("exec approvals", () => {
|
||||||
|
let previousHome: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
previousHome = process.env.HOME;
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-test-"));
|
||||||
|
process.env.HOME = tempDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
if (previousHome === undefined) {
|
||||||
|
delete process.env.HOME;
|
||||||
|
} else {
|
||||||
|
process.env.HOME = previousHome;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses approval id as the node runId", async () => {
|
||||||
|
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||||
|
let invokeParams: unknown;
|
||||||
|
let resolveInvoke: (() => void) | undefined;
|
||||||
|
const invokeSeen = new Promise<void>((resolve) => {
|
||||||
|
resolveInvoke = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
|
||||||
|
if (method === "exec.approval.request") {
|
||||||
|
return { decision: "allow-once" };
|
||||||
|
}
|
||||||
|
if (method === "node.invoke") {
|
||||||
|
invokeParams = params;
|
||||||
|
resolveInvoke?.();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createExecTool } = await import("./bash-tools.exec.js");
|
||||||
|
const tool = createExecTool({
|
||||||
|
host: "node",
|
||||||
|
ask: "always",
|
||||||
|
approvalRunningNoticeMs: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await tool.execute("call1", { command: "ls -la" });
|
||||||
|
expect(result.details.status).toBe("approval-pending");
|
||||||
|
const approvalId = (result.details as { approvalId: string }).approvalId;
|
||||||
|
|
||||||
|
await invokeSeen;
|
||||||
|
|
||||||
|
const runId = (invokeParams as { params?: { runId?: string } } | undefined)?.params?.runId;
|
||||||
|
expect(runId).toBe(approvalId);
|
||||||
|
});
|
||||||
|
});
|
||||||
+734
-395
File diff suppressed because it is too large.
Load diff
+47
-9
@@ -1,15 +1,7 @@
|
|||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { sanitizeSessionMessagesImages } from "./pi-embedded-helpers.js";
|
import { sanitizeSessionMessagesImages } from "./pi-embedded-helpers.js";
|
||||||
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
|
|
||||||
|
|
||||||
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
|
|
||||||
name: DEFAULT_AGENTS_FILENAME,
|
|
||||||
path: "/tmp/AGENTS.md",
|
|
||||||
content: "",
|
|
||||||
missing: false,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
describe("sanitizeSessionMessagesImages", () => {
|
describe("sanitizeSessionMessagesImages", () => {
|
||||||
it("keeps tool call + tool result IDs unchanged by default", async () => {
|
it("keeps tool call + tool result IDs unchanged by default", async () => {
|
||||||
const input = [
|
const input = [
|
||||||
@@ -50,7 +42,8 @@ describe("sanitizeSessionMessagesImages", () => {
|
|||||||
expect(toolResult.role).toBe("toolResult");
|
expect(toolResult.role).toBe("toolResult");
|
||||||
expect(toolResult.toolCallId).toBe("call_123|fc_456");
|
expect(toolResult.toolCallId).toBe("call_123|fc_456");
|
||||||
});
|
});
|
||||||
it("sanitizes tool call + tool result IDs when enabled", async () => {
|
|
||||||
|
it("sanitizes tool call + tool result IDs in standard mode (preserves underscores)", async () => {
|
||||||
const input = [
|
const input = [
|
||||||
{
|
{
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
@@ -82,6 +75,7 @@ describe("sanitizeSessionMessagesImages", () => {
|
|||||||
const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find(
|
const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find(
|
||||||
(b) => b.type === "toolCall",
|
(b) => b.type === "toolCall",
|
||||||
);
|
);
|
||||||
|
// Standard mode preserves underscores for readability, replaces invalid chars
|
||||||
expect(toolCall?.id).toBe("call_123_fc_456");
|
expect(toolCall?.id).toBe("call_123_fc_456");
|
||||||
|
|
||||||
const toolResult = out[1] as unknown as {
|
const toolResult = out[1] as unknown as {
|
||||||
@@ -91,6 +85,50 @@ describe("sanitizeSessionMessagesImages", () => {
|
|||||||
expect(toolResult.role).toBe("toolResult");
|
expect(toolResult.role).toBe("toolResult");
|
||||||
expect(toolResult.toolCallId).toBe("call_123_fc_456");
|
expect(toolResult.toolCallId).toBe("call_123_fc_456");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sanitizes tool call + tool result IDs in strict mode (alphanumeric only)", async () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "toolCall",
|
||||||
|
id: "call_123|fc_456",
|
||||||
|
name: "read",
|
||||||
|
arguments: { path: "package.json" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: "call_123|fc_456",
|
||||||
|
toolName: "read",
|
||||||
|
content: [{ type: "text", text: "ok" }],
|
||||||
|
isError: false,
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = await sanitizeSessionMessagesImages(input, "test", {
|
||||||
|
sanitizeToolCallIds: true,
|
||||||
|
toolCallIdMode: "strict",
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistant = out[0] as unknown as { role?: string; content?: unknown };
|
||||||
|
expect(assistant.role).toBe("assistant");
|
||||||
|
expect(Array.isArray(assistant.content)).toBe(true);
|
||||||
|
const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find(
|
||||||
|
(b) => b.type === "toolCall",
|
||||||
|
);
|
||||||
|
// Strict mode strips all non-alphanumeric characters
|
||||||
|
expect(toolCall?.id).toBe("call123fc456");
|
||||||
|
|
||||||
|
const toolResult = out[1] as unknown as {
|
||||||
|
role?: string;
|
||||||
|
toolCallId?: string;
|
||||||
|
};
|
||||||
|
expect(toolResult.role).toBe("toolResult");
|
||||||
|
expect(toolResult.toolCallId).toBe("call123fc456");
|
||||||
|
});
|
||||||
it("drops assistant blocks after a tool call when enforceToolCallLast is enabled", async () => {
|
it("drops assistant blocks after a tool call when enforceToolCallLast is enabled", async () => {
|
||||||
const input = [
|
const input = [
|
||||||
{
|
{
|
||||||
|
|||||||
+38
-9
@@ -1,15 +1,7 @@
|
|||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { sanitizeSessionMessagesImages } from "./pi-embedded-helpers.js";
|
import { sanitizeSessionMessagesImages } from "./pi-embedded-helpers.js";
|
||||||
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
|
|
||||||
|
|
||||||
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
|
|
||||||
name: DEFAULT_AGENTS_FILENAME,
|
|
||||||
path: "/tmp/AGENTS.md",
|
|
||||||
content: "",
|
|
||||||
missing: false,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
describe("sanitizeSessionMessagesImages", () => {
|
describe("sanitizeSessionMessagesImages", () => {
|
||||||
it("removes empty assistant text blocks but preserves tool calls", async () => {
|
it("removes empty assistant text blocks but preserves tool calls", async () => {
|
||||||
const input = [
|
const input = [
|
||||||
@@ -30,7 +22,8 @@ describe("sanitizeSessionMessagesImages", () => {
|
|||||||
expect(content).toHaveLength(1);
|
expect(content).toHaveLength(1);
|
||||||
expect((content as Array<{ type?: string }>)[0]?.type).toBe("toolCall");
|
expect((content as Array<{ type?: string }>)[0]?.type).toBe("toolCall");
|
||||||
});
|
});
|
||||||
it("sanitizes tool ids for assistant blocks and tool results when enabled", async () => {
|
|
||||||
|
it("sanitizes tool ids in standard mode (preserves underscores)", async () => {
|
||||||
const input = [
|
const input = [
|
||||||
{
|
{
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
@@ -55,6 +48,7 @@ describe("sanitizeSessionMessagesImages", () => {
|
|||||||
sanitizeToolCallIds: true,
|
sanitizeToolCallIds: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Standard mode preserves underscores for readability
|
||||||
const assistant = out[0] as { content?: Array<{ id?: string }> };
|
const assistant = out[0] as { content?: Array<{ id?: string }> };
|
||||||
expect(assistant.content?.[0]?.id).toBe("call_abc_item_123");
|
expect(assistant.content?.[0]?.id).toBe("call_abc_item_123");
|
||||||
expect(assistant.content?.[1]?.id).toBe("call_abc_item_456");
|
expect(assistant.content?.[1]?.id).toBe("call_abc_item_456");
|
||||||
@@ -62,6 +56,41 @@ describe("sanitizeSessionMessagesImages", () => {
|
|||||||
const toolResult = out[1] as { toolUseId?: string };
|
const toolResult = out[1] as { toolUseId?: string };
|
||||||
expect(toolResult.toolUseId).toBe("call_abc_item_123");
|
expect(toolResult.toolUseId).toBe("call_abc_item_123");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sanitizes tool ids in strict mode (alphanumeric only)", async () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "toolUse", id: "call_abc|item:123", name: "test", input: {} },
|
||||||
|
{
|
||||||
|
type: "toolCall",
|
||||||
|
id: "call_abc|item:456",
|
||||||
|
name: "exec",
|
||||||
|
arguments: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolUseId: "call_abc|item:123",
|
||||||
|
content: [{ type: "text", text: "ok" }],
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = await sanitizeSessionMessagesImages(input, "test", {
|
||||||
|
sanitizeToolCallIds: true,
|
||||||
|
toolCallIdMode: "strict",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Strict mode strips all non-alphanumeric characters
|
||||||
|
const assistant = out[0] as { content?: Array<{ id?: string }> };
|
||||||
|
expect(assistant.content?.[0]?.id).toBe("callabcitem123");
|
||||||
|
expect(assistant.content?.[1]?.id).toBe("callabcitem456");
|
||||||
|
|
||||||
|
const toolResult = out[1] as { toolUseId?: string };
|
||||||
|
expect(toolResult.toolUseId).toBe("callabcitem123");
|
||||||
|
});
|
||||||
it("filters whitespace-only assistant text blocks", async () => {
|
it("filters whitespace-only assistant text blocks", async () => {
|
||||||
const input = [
|
const input = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,22 +1,43 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { sanitizeToolCallId } from "./pi-embedded-helpers.js";
|
import { sanitizeToolCallId } from "./pi-embedded-helpers.js";
|
||||||
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
|
|
||||||
|
|
||||||
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
|
|
||||||
name: DEFAULT_AGENTS_FILENAME,
|
|
||||||
path: "/tmp/AGENTS.md",
|
|
||||||
content: "",
|
|
||||||
missing: false,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
describe("sanitizeToolCallId", () => {
|
describe("sanitizeToolCallId", () => {
|
||||||
it("keeps valid tool call IDs", () => {
|
describe("standard mode (default)", () => {
|
||||||
expect(sanitizeToolCallId("call_abc-123")).toBe("call_abc-123");
|
it("keeps valid alphanumeric tool call IDs", () => {
|
||||||
|
expect(sanitizeToolCallId("callabc123")).toBe("callabc123");
|
||||||
|
});
|
||||||
|
it("keeps underscores and hyphens for readability", () => {
|
||||||
|
expect(sanitizeToolCallId("call_abc-123")).toBe("call_abc-123");
|
||||||
|
expect(sanitizeToolCallId("call_abc_def")).toBe("call_abc_def");
|
||||||
|
});
|
||||||
|
it("replaces invalid characters with underscores", () => {
|
||||||
|
expect(sanitizeToolCallId("call_abc|item:456")).toBe("call_abc_item_456");
|
||||||
|
});
|
||||||
|
it("returns default for empty IDs", () => {
|
||||||
|
expect(sanitizeToolCallId("")).toBe("default_tool_id");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
it("replaces invalid characters with underscores", () => {
|
|
||||||
expect(sanitizeToolCallId("call_abc|item:456")).toBe("call_abc_item_456");
|
describe("strict mode (alphanumeric only)", () => {
|
||||||
|
it("strips all non-alphanumeric characters", () => {
|
||||||
|
expect(sanitizeToolCallId("call_abc-123", "strict")).toBe("callabc123");
|
||||||
|
expect(sanitizeToolCallId("call_abc|item:456", "strict")).toBe("callabcitem456");
|
||||||
|
expect(sanitizeToolCallId("whatsapp_login_1768799841527_1", "strict")).toBe(
|
||||||
|
"whatsapplogin17687998415271",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("returns default for empty IDs", () => {
|
||||||
|
expect(sanitizeToolCallId("", "strict")).toBe("defaulttoolid");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
it("returns default for empty IDs", () => {
|
|
||||||
expect(sanitizeToolCallId("")).toBe("default_tool_id");
|
describe("strict9 mode (Mistral tool call IDs)", () => {
|
||||||
|
it("returns alphanumeric IDs with length 9", () => {
|
||||||
|
const out = sanitizeToolCallId("call_abc|item:456", "strict9");
|
||||||
|
expect(out).toMatch(/^[a-zA-Z0-9]{9}$/);
|
||||||
|
});
|
||||||
|
it("returns default for empty IDs", () => {
|
||||||
|
expect(sanitizeToolCallId("", "strict9")).toMatch(/^[a-zA-Z0-9]{9}$/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -50,4 +50,5 @@ export {
|
|||||||
} from "./pi-embedded-helpers/turns.js";
|
} from "./pi-embedded-helpers/turns.js";
|
||||||
export type { EmbeddedContextFile, FailoverReason } from "./pi-embedded-helpers/types.js";
|
export type { EmbeddedContextFile, FailoverReason } from "./pi-embedded-helpers/types.js";
|
||||||
|
|
||||||
|
export type { ToolCallIdMode } from "./tool-call-id.js";
|
||||||
export { isValidCloudCodeAssistToolId, sanitizeToolCallId } from "./tool-call-id.js";
|
export { isValidCloudCodeAssistToolId, sanitizeToolCallId } from "./tool-call-id.js";
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { AgentMessage, AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
|
|
||||||
|
import type { ToolCallIdMode } from "../tool-call-id.js";
|
||||||
import { sanitizeToolCallIdsForCloudCodeAssist } from "../tool-call-id.js";
|
import { sanitizeToolCallIdsForCloudCodeAssist } from "../tool-call-id.js";
|
||||||
import { sanitizeContentBlocksImages } from "../tool-images.js";
|
import { sanitizeContentBlocksImages } from "../tool-images.js";
|
||||||
import { stripThoughtSignatures } from "./bootstrap.js";
|
import { stripThoughtSignatures } from "./bootstrap.js";
|
||||||
@@ -32,6 +33,13 @@ export async function sanitizeSessionMessagesImages(
|
|||||||
label: string,
|
label: string,
|
||||||
options?: {
|
options?: {
|
||||||
sanitizeToolCallIds?: boolean;
|
sanitizeToolCallIds?: boolean;
|
||||||
|
/**
|
||||||
|
* Mode for tool call ID sanitization:
|
||||||
|
* - "standard" (default, preserves _-)
|
||||||
|
* - "strict" (alphanumeric only)
|
||||||
|
* - "strict9" (alphanumeric only, length 9)
|
||||||
|
*/
|
||||||
|
toolCallIdMode?: ToolCallIdMode;
|
||||||
enforceToolCallLast?: boolean;
|
enforceToolCallLast?: boolean;
|
||||||
preserveSignatures?: boolean;
|
preserveSignatures?: boolean;
|
||||||
sanitizeThoughtSignatures?: {
|
sanitizeThoughtSignatures?: {
|
||||||
@@ -43,7 +51,7 @@ export async function sanitizeSessionMessagesImages(
|
|||||||
// We sanitize historical session messages because Anthropic can reject a request
|
// We sanitize historical session messages because Anthropic can reject a request
|
||||||
// if the transcript contains oversized base64 images (see MAX_IMAGE_DIMENSION_PX).
|
// if the transcript contains oversized base64 images (see MAX_IMAGE_DIMENSION_PX).
|
||||||
const sanitizedIds = options?.sanitizeToolCallIds
|
const sanitizedIds = options?.sanitizeToolCallIds
|
||||||
? sanitizeToolCallIdsForCloudCodeAssist(messages)
|
? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode)
|
||||||
: messages;
|
: messages;
|
||||||
const out: AgentMessage[] = [];
|
const out: AgentMessage[] = [];
|
||||||
for (const msg of sanitizedIds) {
|
for (const msg of sanitizedIds) {
|
||||||
|
|||||||
@@ -54,6 +54,25 @@ describe("sanitizeSessionHistory", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sanitizes tool call ids with strict9 for Mistral models", async () => {
|
||||||
|
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
||||||
|
|
||||||
|
await sanitizeSessionHistory({
|
||||||
|
messages: mockMessages,
|
||||||
|
modelApi: "openai-responses",
|
||||||
|
provider: "openrouter",
|
||||||
|
modelId: "mistralai/devstral-2512:free",
|
||||||
|
sessionManager: mockSessionManager,
|
||||||
|
sessionId: "test-session",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
|
||||||
|
mockMessages,
|
||||||
|
"session:history",
|
||||||
|
expect.objectContaining({ sanitizeToolCallIds: true, toolCallIdMode: "strict9" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not sanitize tool call ids for non-Google, non-OpenAI APIs", async () => {
|
it("does not sanitize tool call ids for non-Google, non-OpenAI APIs", async () => {
|
||||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { log } from "./logger.js";
|
|||||||
import { describeUnknownError } from "./utils.js";
|
import { describeUnknownError } from "./utils.js";
|
||||||
import { isAntigravityClaude } from "../pi-embedded-helpers/google.js";
|
import { isAntigravityClaude } from "../pi-embedded-helpers/google.js";
|
||||||
import { cleanToolSchemaForGemini } from "../pi-tools.schema.js";
|
import { cleanToolSchemaForGemini } from "../pi-tools.schema.js";
|
||||||
|
import { normalizeProviderId } from "../model-selection.js";
|
||||||
|
import type { ToolCallIdMode } from "../tool-call-id.js";
|
||||||
|
|
||||||
const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap";
|
const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap";
|
||||||
const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
|
const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
|
||||||
@@ -44,12 +46,29 @@ const OPENAI_TOOL_CALL_ID_APIS = new Set([
|
|||||||
"openai-responses",
|
"openai-responses",
|
||||||
"openai-codex-responses",
|
"openai-codex-responses",
|
||||||
]);
|
]);
|
||||||
|
const MISTRAL_MODEL_HINTS = [
|
||||||
|
"mistral",
|
||||||
|
"mixtral",
|
||||||
|
"codestral",
|
||||||
|
"pixtral",
|
||||||
|
"devstral",
|
||||||
|
"ministral",
|
||||||
|
"mistralai",
|
||||||
|
];
|
||||||
|
|
||||||
function shouldSanitizeToolCallIds(modelApi?: string | null): boolean {
|
function shouldSanitizeToolCallIds(modelApi?: string | null): boolean {
|
||||||
if (!modelApi) return false;
|
if (!modelApi) return false;
|
||||||
return isGoogleModelApi(modelApi) || OPENAI_TOOL_CALL_ID_APIS.has(modelApi);
|
return isGoogleModelApi(modelApi) || OPENAI_TOOL_CALL_ID_APIS.has(modelApi);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMistralModel(params: { provider?: string | null; modelId?: string | null }): boolean {
|
||||||
|
const provider = normalizeProviderId(params.provider ?? "");
|
||||||
|
if (provider === "mistral") return true;
|
||||||
|
const modelId = (params.modelId ?? "").toLowerCase();
|
||||||
|
if (!modelId) return false;
|
||||||
|
return MISTRAL_MODEL_HINTS.some((hint) => modelId.includes(hint));
|
||||||
|
}
|
||||||
|
|
||||||
function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] {
|
function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] {
|
||||||
if (!schema || typeof schema !== "object") return [];
|
if (!schema || typeof schema !== "object") return [];
|
||||||
if (Array.isArray(schema)) {
|
if (Array.isArray(schema)) {
|
||||||
@@ -191,12 +210,16 @@ export async function sanitizeSessionHistory(params: {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
}): Promise<AgentMessage[]> {
|
}): Promise<AgentMessage[]> {
|
||||||
const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId);
|
const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId);
|
||||||
const provider = (params.provider ?? "").toLowerCase();
|
const provider = normalizeProviderId(params.provider ?? "");
|
||||||
const modelId = (params.modelId ?? "").toLowerCase();
|
const modelId = (params.modelId ?? "").toLowerCase();
|
||||||
const isOpenRouterGemini =
|
const isOpenRouterGemini =
|
||||||
(provider === "openrouter" || provider === "opencode") && modelId.includes("gemini");
|
(provider === "openrouter" || provider === "opencode") && modelId.includes("gemini");
|
||||||
|
const isMistral = isMistralModel({ provider, modelId });
|
||||||
|
const toolCallIdMode: ToolCallIdMode | undefined = isMistral ? "strict9" : undefined;
|
||||||
|
const sanitizeToolCallIds = shouldSanitizeToolCallIds(params.modelApi) || isMistral;
|
||||||
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
|
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
|
||||||
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
|
sanitizeToolCallIds,
|
||||||
|
toolCallIdMode,
|
||||||
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
||||||
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
|
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
|
||||||
sanitizeThoughtSignatures: isOpenRouterGemini
|
sanitizeThoughtSignatures: isOpenRouterGemini
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ function resolveExecConfig(cfg: ClawdbotConfig | undefined) {
|
|||||||
pathPrepend: globalExec?.pathPrepend,
|
pathPrepend: globalExec?.pathPrepend,
|
||||||
backgroundMs: globalExec?.backgroundMs,
|
backgroundMs: globalExec?.backgroundMs,
|
||||||
timeoutSec: globalExec?.timeoutSec,
|
timeoutSec: globalExec?.timeoutSec,
|
||||||
|
approvalRunningNoticeMs: globalExec?.approvalRunningNoticeMs,
|
||||||
cleanupMs: globalExec?.cleanupMs,
|
cleanupMs: globalExec?.cleanupMs,
|
||||||
notifyOnExit: globalExec?.notifyOnExit,
|
notifyOnExit: globalExec?.notifyOnExit,
|
||||||
applyPatch: globalExec?.applyPatch,
|
applyPatch: globalExec?.applyPatch,
|
||||||
@@ -219,6 +220,8 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
messageProvider: options?.messageProvider,
|
messageProvider: options?.messageProvider,
|
||||||
backgroundMs: options?.exec?.backgroundMs ?? execConfig.backgroundMs,
|
backgroundMs: options?.exec?.backgroundMs ?? execConfig.backgroundMs,
|
||||||
timeoutSec: options?.exec?.timeoutSec ?? execConfig.timeoutSec,
|
timeoutSec: options?.exec?.timeoutSec ?? execConfig.timeoutSec,
|
||||||
|
approvalRunningNoticeMs:
|
||||||
|
options?.exec?.approvalRunningNoticeMs ?? execConfig.approvalRunningNoticeMs,
|
||||||
notifyOnExit: options?.exec?.notifyOnExit ?? execConfig.notifyOnExit,
|
notifyOnExit: options?.exec?.notifyOnExit ?? execConfig.notifyOnExit,
|
||||||
sandbox: sandbox
|
sandbox: sandbox
|
||||||
? {
|
? {
|
||||||
|
|||||||
+246
-90
@@ -7,106 +7,262 @@ import {
|
|||||||
} from "./tool-call-id.js";
|
} from "./tool-call-id.js";
|
||||||
|
|
||||||
describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
|
describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
|
||||||
it("is a no-op for already-valid non-colliding IDs", () => {
|
describe("standard mode (default)", () => {
|
||||||
const input = [
|
it("is a no-op for already-valid non-colliding IDs", () => {
|
||||||
{
|
const input = [
|
||||||
role: "assistant",
|
{
|
||||||
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
|
role: "assistant",
|
||||||
},
|
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
|
||||||
{
|
},
|
||||||
role: "toolResult",
|
{
|
||||||
toolCallId: "call_1",
|
role: "toolResult",
|
||||||
toolName: "read",
|
toolCallId: "call_1",
|
||||||
content: [{ type: "text", text: "ok" }],
|
toolName: "read",
|
||||||
},
|
content: [{ type: "text", text: "ok" }],
|
||||||
] satisfies AgentMessage[];
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
|
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
|
||||||
expect(out).toBe(input);
|
expect(out).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces invalid characters with underscores (preserves readability)", () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "toolCall", id: "call|item:123", name: "read", arguments: {} }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: "call|item:123",
|
||||||
|
toolName: "read",
|
||||||
|
content: [{ type: "text", text: "ok" }],
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
|
||||||
|
expect(out).not.toBe(input);
|
||||||
|
|
||||||
|
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
||||||
|
const toolCall = assistant.content?.[0] as { id?: string };
|
||||||
|
// Standard mode preserves underscores for readability
|
||||||
|
expect(toolCall.id).toBe("call_item_123");
|
||||||
|
expect(isValidCloudCodeAssistToolId(toolCall.id as string)).toBe(true);
|
||||||
|
|
||||||
|
const result = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||||
|
expect(result.toolCallId).toBe(toolCall.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("avoids collisions when sanitization would produce duplicate IDs", () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "toolCall", id: "call_a|b", name: "read", arguments: {} },
|
||||||
|
{ type: "toolCall", id: "call_a:b", name: "read", arguments: {} },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: "call_a|b",
|
||||||
|
toolName: "read",
|
||||||
|
content: [{ type: "text", text: "one" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: "call_a:b",
|
||||||
|
toolName: "read",
|
||||||
|
content: [{ type: "text", text: "two" }],
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
|
||||||
|
expect(out).not.toBe(input);
|
||||||
|
|
||||||
|
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
||||||
|
const a = assistant.content?.[0] as { id?: string };
|
||||||
|
const b = assistant.content?.[1] as { id?: string };
|
||||||
|
expect(typeof a.id).toBe("string");
|
||||||
|
expect(typeof b.id).toBe("string");
|
||||||
|
expect(a.id).not.toBe(b.id);
|
||||||
|
expect(isValidCloudCodeAssistToolId(a.id as string)).toBe(true);
|
||||||
|
expect(isValidCloudCodeAssistToolId(b.id as string)).toBe(true);
|
||||||
|
|
||||||
|
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||||
|
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||||
|
expect(r1.toolCallId).toBe(a.id);
|
||||||
|
expect(r2.toolCallId).toBe(b.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps tool call IDs at 40 chars while preserving uniqueness", () => {
|
||||||
|
const longA = `call_${"a".repeat(60)}`;
|
||||||
|
const longB = `call_${"a".repeat(59)}b`;
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "toolCall", id: longA, name: "read", arguments: {} },
|
||||||
|
{ type: "toolCall", id: longB, name: "read", arguments: {} },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: longA,
|
||||||
|
toolName: "read",
|
||||||
|
content: [{ type: "text", text: "one" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: longB,
|
||||||
|
toolName: "read",
|
||||||
|
content: [{ type: "text", text: "two" }],
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
|
||||||
|
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
||||||
|
const a = assistant.content?.[0] as { id?: string };
|
||||||
|
const b = assistant.content?.[1] as { id?: string };
|
||||||
|
|
||||||
|
expect(typeof a.id).toBe("string");
|
||||||
|
expect(typeof b.id).toBe("string");
|
||||||
|
expect(a.id).not.toBe(b.id);
|
||||||
|
expect(a.id?.length).toBeLessThanOrEqual(40);
|
||||||
|
expect(b.id?.length).toBeLessThanOrEqual(40);
|
||||||
|
expect(isValidCloudCodeAssistToolId(a.id as string)).toBe(true);
|
||||||
|
expect(isValidCloudCodeAssistToolId(b.id as string)).toBe(true);
|
||||||
|
|
||||||
|
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||||
|
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||||
|
expect(r1.toolCallId).toBe(a.id);
|
||||||
|
expect(r2.toolCallId).toBe(b.id);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("avoids collisions when sanitization would produce duplicate IDs", () => {
|
describe("strict mode (alphanumeric only)", () => {
|
||||||
const input = [
|
it("strips underscores and hyphens from tool call IDs", () => {
|
||||||
{
|
const input = [
|
||||||
role: "assistant",
|
{
|
||||||
content: [
|
role: "assistant",
|
||||||
{ type: "toolCall", id: "call_a|b", name: "read", arguments: {} },
|
content: [
|
||||||
{ type: "toolCall", id: "call_a:b", name: "read", arguments: {} },
|
{
|
||||||
],
|
type: "toolCall",
|
||||||
},
|
id: "whatsapp_login_1768799841527_1",
|
||||||
{
|
name: "login",
|
||||||
role: "toolResult",
|
arguments: {},
|
||||||
toolCallId: "call_a|b",
|
},
|
||||||
toolName: "read",
|
],
|
||||||
content: [{ type: "text", text: "one" }],
|
},
|
||||||
},
|
{
|
||||||
{
|
role: "toolResult",
|
||||||
role: "toolResult",
|
toolCallId: "whatsapp_login_1768799841527_1",
|
||||||
toolCallId: "call_a:b",
|
toolName: "login",
|
||||||
toolName: "read",
|
content: [{ type: "text", text: "ok" }],
|
||||||
content: [{ type: "text", text: "two" }],
|
},
|
||||||
},
|
] satisfies AgentMessage[];
|
||||||
] satisfies AgentMessage[];
|
|
||||||
|
|
||||||
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
|
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict");
|
||||||
expect(out).not.toBe(input);
|
expect(out).not.toBe(input);
|
||||||
|
|
||||||
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
||||||
const a = assistant.content?.[0] as { id?: string };
|
const toolCall = assistant.content?.[0] as { id?: string };
|
||||||
const b = assistant.content?.[1] as { id?: string };
|
// Strict mode strips all non-alphanumeric characters
|
||||||
expect(typeof a.id).toBe("string");
|
expect(toolCall.id).toBe("whatsapplogin17687998415271");
|
||||||
expect(typeof b.id).toBe("string");
|
expect(isValidCloudCodeAssistToolId(toolCall.id as string, "strict")).toBe(true);
|
||||||
expect(a.id).not.toBe(b.id);
|
|
||||||
expect(isValidCloudCodeAssistToolId(a.id as string)).toBe(true);
|
|
||||||
expect(isValidCloudCodeAssistToolId(b.id as string)).toBe(true);
|
|
||||||
|
|
||||||
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
const result = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||||
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
|
expect(result.toolCallId).toBe(toolCall.id);
|
||||||
expect(r1.toolCallId).toBe(a.id);
|
});
|
||||||
expect(r2.toolCallId).toBe(b.id);
|
|
||||||
|
it("avoids collisions with alphanumeric-only suffixes", () => {
|
||||||
|
const input = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "toolCall", id: "call_a|b", name: "read", arguments: {} },
|
||||||
|
{ type: "toolCall", id: "call_a:b", name: "read", arguments: {} },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: "call_a|b",
|
||||||
|
toolName: "read",
|
||||||
|
content: [{ type: "text", text: "one" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: "call_a:b",
|
||||||
|
toolName: "read",
|
||||||
|
content: [{ type: "text", text: "two" }],
|
||||||
|
},
|
||||||
|
] satisfies AgentMessage[];
|
||||||
|
|
||||||
|
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict");
|
||||||
|
expect(out).not.toBe(input);
|
||||||
|
|
||||||
|
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
||||||
|
const a = assistant.content?.[0] as { id?: string };
|
||||||
|
const b = assistant.content?.[1] as { id?: string };
|
||||||
|
expect(typeof a.id).toBe("string");
|
||||||
|
expect(typeof b.id).toBe("string");
|
||||||
|
expect(a.id).not.toBe(b.id);
|
||||||
|
// Both should be strictly alphanumeric
|
||||||
|
expect(isValidCloudCodeAssistToolId(a.id as string, "strict")).toBe(true);
|
||||||
|
expect(isValidCloudCodeAssistToolId(b.id as string, "strict")).toBe(true);
|
||||||
|
// Should not contain underscores or hyphens
|
||||||
|
expect(a.id).not.toMatch(/[_-]/);
|
||||||
|
expect(b.id).not.toMatch(/[_-]/);
|
||||||
|
|
||||||
|
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||||
|
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||||
|
expect(r1.toolCallId).toBe(a.id);
|
||||||
|
expect(r2.toolCallId).toBe(b.id);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("caps tool call IDs at 40 chars while preserving uniqueness", () => {
|
describe("strict9 mode (Mistral tool call IDs)", () => {
|
||||||
const longA = `call_${"a".repeat(60)}`;
|
it("enforces alphanumeric IDs with length 9", () => {
|
||||||
const longB = `call_${"a".repeat(59)}b`;
|
const input = [
|
||||||
const input = [
|
{
|
||||||
{
|
role: "assistant",
|
||||||
role: "assistant",
|
content: [
|
||||||
content: [
|
{ type: "toolCall", id: "call_abc|item:123", name: "read", arguments: {} },
|
||||||
{ type: "toolCall", id: longA, name: "read", arguments: {} },
|
{ type: "toolCall", id: "call_abc|item:456", name: "read", arguments: {} },
|
||||||
{ type: "toolCall", id: longB, name: "read", arguments: {} },
|
],
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
role: "toolResult",
|
||||||
role: "toolResult",
|
toolCallId: "call_abc|item:123",
|
||||||
toolCallId: longA,
|
toolName: "read",
|
||||||
toolName: "read",
|
content: [{ type: "text", text: "one" }],
|
||||||
content: [{ type: "text", text: "one" }],
|
},
|
||||||
},
|
{
|
||||||
{
|
role: "toolResult",
|
||||||
role: "toolResult",
|
toolCallId: "call_abc|item:456",
|
||||||
toolCallId: longB,
|
toolName: "read",
|
||||||
toolName: "read",
|
content: [{ type: "text", text: "two" }],
|
||||||
content: [{ type: "text", text: "two" }],
|
},
|
||||||
},
|
] satisfies AgentMessage[];
|
||||||
] satisfies AgentMessage[];
|
|
||||||
|
|
||||||
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
|
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict9");
|
||||||
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
expect(out).not.toBe(input);
|
||||||
const a = assistant.content?.[0] as { id?: string };
|
|
||||||
const b = assistant.content?.[1] as { id?: string };
|
|
||||||
|
|
||||||
expect(typeof a.id).toBe("string");
|
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
||||||
expect(typeof b.id).toBe("string");
|
const a = assistant.content?.[0] as { id?: string };
|
||||||
expect(a.id).not.toBe(b.id);
|
const b = assistant.content?.[1] as { id?: string };
|
||||||
expect(a.id?.length).toBeLessThanOrEqual(40);
|
|
||||||
expect(b.id?.length).toBeLessThanOrEqual(40);
|
|
||||||
expect(isValidCloudCodeAssistToolId(a.id as string)).toBe(true);
|
|
||||||
expect(isValidCloudCodeAssistToolId(b.id as string)).toBe(true);
|
|
||||||
|
|
||||||
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
expect(typeof a.id).toBe("string");
|
||||||
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
|
expect(typeof b.id).toBe("string");
|
||||||
expect(r1.toolCallId).toBe(a.id);
|
expect(a.id).not.toBe(b.id);
|
||||||
expect(r2.toolCallId).toBe(b.id);
|
expect(a.id?.length).toBe(9);
|
||||||
|
expect(b.id?.length).toBe(9);
|
||||||
|
expect(isValidCloudCodeAssistToolId(a.id as string, "strict9")).toBe(true);
|
||||||
|
expect(isValidCloudCodeAssistToolId(b.id as string, "strict9")).toBe(true);
|
||||||
|
|
||||||
|
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||||
|
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||||
|
expect(r1.toolCallId).toBe(a.id);
|
||||||
|
expect(r2.toolCallId).toBe(b.id);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+82
-21
@@ -2,46 +2,96 @@ import { createHash } from "node:crypto";
|
|||||||
|
|
||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
|
|
||||||
export function sanitizeToolCallId(id: string): string {
|
export type ToolCallIdMode = "standard" | "strict" | "strict9";
|
||||||
if (!id || typeof id !== "string") return "default_tool_id";
|
|
||||||
|
|
||||||
const cloudCodeAssistPatternReplacement = id.replace(/[^a-zA-Z0-9_-]/g, "_");
|
const STRICT9_LEN = 9;
|
||||||
const trimmedInvalidStartChars = cloudCodeAssistPatternReplacement.replace(
|
|
||||||
/^[^a-zA-Z0-9_-]+/,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
|
|
||||||
return trimmedInvalidStartChars.length > 0 ? trimmedInvalidStartChars : "sanitized_tool_id";
|
/**
|
||||||
|
* Sanitize a tool call ID to be compatible with various providers.
|
||||||
|
*
|
||||||
|
* - "standard" mode: allows [a-zA-Z0-9_-], better readability (default)
|
||||||
|
* - "strict" mode: only [a-zA-Z0-9]
|
||||||
|
* - "strict9" mode: only [a-zA-Z0-9], length 9 (Mistral tool call requirement)
|
||||||
|
*/
|
||||||
|
export function sanitizeToolCallId(id: string, mode: ToolCallIdMode = "standard"): string {
|
||||||
|
if (!id || typeof id !== "string") {
|
||||||
|
if (mode === "strict9") return "defaultid";
|
||||||
|
return mode === "strict" ? "defaulttoolid" : "default_tool_id";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "strict") {
|
||||||
|
// Some providers require strictly alphanumeric tool call IDs.
|
||||||
|
const alphanumericOnly = id.replace(/[^a-zA-Z0-9]/g, "");
|
||||||
|
return alphanumericOnly.length > 0 ? alphanumericOnly : "sanitizedtoolid";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "strict9") {
|
||||||
|
const alphanumericOnly = id.replace(/[^a-zA-Z0-9]/g, "");
|
||||||
|
if (alphanumericOnly.length >= STRICT9_LEN) return alphanumericOnly.slice(0, STRICT9_LEN);
|
||||||
|
if (alphanumericOnly.length > 0) return shortHash(alphanumericOnly, STRICT9_LEN);
|
||||||
|
return shortHash("sanitized", STRICT9_LEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard mode: allow underscores and hyphens for better readability in logs
|
||||||
|
const sanitized = id.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||||
|
const trimmed = sanitized.replace(/^[^a-zA-Z0-9_-]+/, "");
|
||||||
|
return trimmed.length > 0 ? trimmed : "sanitized_tool_id";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isValidCloudCodeAssistToolId(id: string): boolean {
|
export function isValidCloudCodeAssistToolId(
|
||||||
|
id: string,
|
||||||
|
mode: ToolCallIdMode = "standard",
|
||||||
|
): boolean {
|
||||||
if (!id || typeof id !== "string") return false;
|
if (!id || typeof id !== "string") return false;
|
||||||
|
if (mode === "strict") {
|
||||||
|
// Strictly alphanumeric for providers with tighter tool ID constraints
|
||||||
|
return /^[a-zA-Z0-9]+$/.test(id);
|
||||||
|
}
|
||||||
|
if (mode === "strict9") {
|
||||||
|
return /^[a-zA-Z0-9]{9}$/.test(id);
|
||||||
|
}
|
||||||
|
// Standard mode allows underscores and hyphens
|
||||||
return /^[a-zA-Z0-9_-]+$/.test(id);
|
return /^[a-zA-Z0-9_-]+$/.test(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortHash(text: string): string {
|
function shortHash(text: string, length = 8): string {
|
||||||
return createHash("sha1").update(text).digest("hex").slice(0, 8);
|
return createHash("sha1").update(text).digest("hex").slice(0, length);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeUniqueToolId(params: { id: string; used: Set<string> }): string {
|
function makeUniqueToolId(params: { id: string; used: Set<string>; mode: ToolCallIdMode }): string {
|
||||||
|
if (params.mode === "strict9") {
|
||||||
|
const base = sanitizeToolCallId(params.id, params.mode);
|
||||||
|
const candidate = base.length >= STRICT9_LEN ? base.slice(0, STRICT9_LEN) : "";
|
||||||
|
if (candidate && !params.used.has(candidate)) return candidate;
|
||||||
|
|
||||||
|
for (let i = 0; i < 1000; i += 1) {
|
||||||
|
const hashed = shortHash(`${params.id}:${i}`, STRICT9_LEN);
|
||||||
|
if (!params.used.has(hashed)) return hashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return shortHash(`${params.id}:${Date.now()}`, STRICT9_LEN);
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_LEN = 40;
|
const MAX_LEN = 40;
|
||||||
|
|
||||||
const base = sanitizeToolCallId(params.id).slice(0, MAX_LEN);
|
const base = sanitizeToolCallId(params.id, params.mode).slice(0, MAX_LEN);
|
||||||
if (!params.used.has(base)) return base;
|
if (!params.used.has(base)) return base;
|
||||||
|
|
||||||
const hash = shortHash(params.id);
|
const hash = shortHash(params.id);
|
||||||
const maxBaseLen = MAX_LEN - 1 - hash.length;
|
// Use separator based on mode: underscore for standard (readable), none for strict
|
||||||
|
const separator = params.mode === "strict" ? "" : "_";
|
||||||
|
const maxBaseLen = MAX_LEN - separator.length - hash.length;
|
||||||
const clippedBase = base.length > maxBaseLen ? base.slice(0, maxBaseLen) : base;
|
const clippedBase = base.length > maxBaseLen ? base.slice(0, maxBaseLen) : base;
|
||||||
const candidate = `${clippedBase}_${hash}`;
|
const candidate = `${clippedBase}${separator}${hash}`;
|
||||||
if (!params.used.has(candidate)) return candidate;
|
if (!params.used.has(candidate)) return candidate;
|
||||||
|
|
||||||
for (let i = 2; i < 1000; i += 1) {
|
for (let i = 2; i < 1000; i += 1) {
|
||||||
const suffix = `_${i}`;
|
const suffix = params.mode === "strict" ? `x${i}` : `_${i}`;
|
||||||
const next = `${candidate.slice(0, MAX_LEN - suffix.length)}${suffix}`;
|
const next = `${candidate.slice(0, MAX_LEN - suffix.length)}${suffix}`;
|
||||||
if (!params.used.has(next)) return next;
|
if (!params.used.has(next)) return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ts = `_${Date.now()}`;
|
const ts = params.mode === "strict" ? `t${Date.now()}` : `_${Date.now()}`;
|
||||||
return `${candidate.slice(0, MAX_LEN - ts.length)}${ts}`;
|
return `${candidate.slice(0, MAX_LEN - ts.length)}${ts}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,9 +150,20 @@ function rewriteToolResultIds(params: {
|
|||||||
} as Extract<AgentMessage, { role: "toolResult" }>;
|
} as Extract<AgentMessage, { role: "toolResult" }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeToolCallIdsForCloudCodeAssist(messages: AgentMessage[]): AgentMessage[] {
|
/**
|
||||||
// Cloud Code Assist requires tool IDs matching ^[a-zA-Z0-9_-]+$.
|
* Sanitize tool call IDs for provider compatibility.
|
||||||
// Sanitization can introduce collisions (e.g. `a|b` and `a:b` -> `a_b`).
|
*
|
||||||
|
* @param messages - The messages to sanitize
|
||||||
|
* @param mode - "standard" (default, allows _-), "strict" (alphanumeric only), or "strict9" (alphanumeric length 9)
|
||||||
|
*/
|
||||||
|
export function sanitizeToolCallIdsForCloudCodeAssist(
|
||||||
|
messages: AgentMessage[],
|
||||||
|
mode: ToolCallIdMode = "standard",
|
||||||
|
): AgentMessage[] {
|
||||||
|
// Standard mode: allows [a-zA-Z0-9_-] for better readability in session logs
|
||||||
|
// Strict mode: only [a-zA-Z0-9]
|
||||||
|
// Strict9 mode: only [a-zA-Z0-9], length 9 (Mistral tool call requirement)
|
||||||
|
// Sanitization can introduce collisions (e.g. `a|b` and `a:b` -> `a_b` or `ab`).
|
||||||
// Fix by applying a stable, transcript-wide mapping and de-duping via suffix.
|
// Fix by applying a stable, transcript-wide mapping and de-duping via suffix.
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
const used = new Set<string>();
|
const used = new Set<string>();
|
||||||
@@ -110,7 +171,7 @@ export function sanitizeToolCallIdsForCloudCodeAssist(messages: AgentMessage[]):
|
|||||||
const resolve = (id: string) => {
|
const resolve = (id: string) => {
|
||||||
const existing = map.get(id);
|
const existing = map.get(id);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
const next = makeUniqueToolId({ id, used });
|
const next = makeUniqueToolId({ id, used, mode });
|
||||||
map.set(id, next);
|
map.set(id, next);
|
||||||
used.add(next);
|
used.add(next);
|
||||||
return next;
|
return next;
|
||||||
|
|||||||
+19
-15
@@ -60,7 +60,7 @@ describe("directive behavior", () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists allowlisted models on /model list", async () => {
|
it("aliases /model list to /models", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
@@ -84,13 +84,15 @@ describe("directive behavior", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("Model listing moved.");
|
expect(text).toContain("Providers:");
|
||||||
expect(text).toContain("Use: /models (providers) or /models <provider> (models)");
|
expect(text).toContain("- anthropic");
|
||||||
|
expect(text).toContain("- openai");
|
||||||
|
expect(text).toContain("Use: /models <provider>");
|
||||||
expect(text).toContain("Switch: /model <provider/model>");
|
expect(text).toContain("Switch: /model <provider/model>");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("falls back to configured models when catalog is unavailable", async () => {
|
it("shows current model when catalog is unavailable", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
vi.mocked(loadModelCatalog).mockResolvedValueOnce([]);
|
vi.mocked(loadModelCatalog).mockResolvedValueOnce([]);
|
||||||
@@ -116,15 +118,16 @@ describe("directive behavior", () => {
|
|||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("Current: anthropic/claude-opus-4-5");
|
expect(text).toContain("Current: anthropic/claude-opus-4-5");
|
||||||
|
expect(text).toContain("Switch: /model <provider/model>");
|
||||||
expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
|
expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
|
||||||
expect(text).toContain("More: /model status");
|
expect(text).toContain("More: /model status");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("includes catalog models when no allowlist is set", async () => {
|
it("includes catalog providers when no allowlist is set", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
|
vi.mocked(loadModelCatalog).mockResolvedValue([
|
||||||
{ id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" },
|
{ id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" },
|
||||||
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
|
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
|
||||||
{ id: "grok-4", name: "Grok 4", provider: "xai" },
|
{ id: "grok-4", name: "Grok 4", provider: "xai" },
|
||||||
@@ -150,13 +153,15 @@ describe("directive behavior", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("Model listing moved.");
|
expect(text).toContain("Providers:");
|
||||||
expect(text).toContain("Use: /models (providers) or /models <provider> (models)");
|
expect(text).toContain("- anthropic");
|
||||||
expect(text).toContain("Switch: /model <provider/model>");
|
expect(text).toContain("- openai");
|
||||||
|
expect(text).toContain("- xai");
|
||||||
|
expect(text).toContain("Use: /models <provider>");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("merges config allowlist models even when catalog is present", async () => {
|
it("lists config-only providers when catalog is present", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
// Catalog present but missing custom providers: /model should still include
|
// Catalog present but missing custom providers: /model should still include
|
||||||
@@ -172,7 +177,7 @@ describe("directive behavior", () => {
|
|||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
const res = await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
|
{ Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
@@ -201,9 +206,8 @@ describe("directive behavior", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("Model listing moved.");
|
expect(text).toContain("Model set to minimax");
|
||||||
expect(text).toContain("Use: /models (providers) or /models <provider> (models)");
|
expect(text).toContain("minimax/MiniMax-M2.1");
|
||||||
expect(text).toContain("Switch: /model <provider/model>");
|
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -230,7 +234,7 @@ describe("directive behavior", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("Model listing moved.");
|
expect(text).toContain("Providers:");
|
||||||
expect(text).not.toContain("missing (missing)");
|
expect(text).not.toContain("missing (missing)");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-2
@@ -67,7 +67,7 @@ describe("directive behavior", () => {
|
|||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{ Body: "/model ki", From: "+1222", To: "+1222", CommandAuthorized: true },
|
{ Body: "/model ki", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
@@ -103,9 +103,11 @@ describe("directive behavior", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
|
expect(text).toContain("Model set to Kimi (moonshot/kimi-k2-0905-preview).");
|
||||||
assertModelSelection(storePath, {
|
assertModelSelection(storePath, {
|
||||||
model: "kimi-k2-0905-preview",
|
|
||||||
provider: "moonshot",
|
provider: "moonshot",
|
||||||
|
model: "kimi-k2-0905-preview",
|
||||||
});
|
});
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
+12
-6
@@ -65,7 +65,7 @@ describe("directive behavior", () => {
|
|||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{ Body: "/model kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
{ Body: "/model kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
@@ -94,9 +94,11 @@ describe("directive behavior", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
|
expect(text).toContain("Model set to moonshot/kimi-k2-0905-preview.");
|
||||||
assertModelSelection(storePath, {
|
assertModelSelection(storePath, {
|
||||||
model: "kimi-k2-0905-preview",
|
|
||||||
provider: "moonshot",
|
provider: "moonshot",
|
||||||
|
model: "kimi-k2-0905-preview",
|
||||||
});
|
});
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -106,7 +108,7 @@ describe("directive behavior", () => {
|
|||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{
|
{
|
||||||
Body: "/model kimi-k2-0905-preview",
|
Body: "/model kimi-k2-0905-preview",
|
||||||
From: "+1222",
|
From: "+1222",
|
||||||
@@ -140,9 +142,11 @@ describe("directive behavior", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
|
expect(text).toContain("Model set to moonshot/kimi-k2-0905-preview.");
|
||||||
assertModelSelection(storePath, {
|
assertModelSelection(storePath, {
|
||||||
model: "kimi-k2-0905-preview",
|
|
||||||
provider: "moonshot",
|
provider: "moonshot",
|
||||||
|
model: "kimi-k2-0905-preview",
|
||||||
});
|
});
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -152,7 +156,7 @@ describe("directive behavior", () => {
|
|||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{ Body: "/model moonshot/kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
{ Body: "/model moonshot/kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
@@ -181,9 +185,11 @@ describe("directive behavior", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
|
expect(text).toContain("Model set to moonshot/kimi-k2-0905-preview.");
|
||||||
assertModelSelection(storePath, {
|
assertModelSelection(storePath, {
|
||||||
model: "kimi-k2-0905-preview",
|
|
||||||
provider: "moonshot",
|
provider: "moonshot",
|
||||||
|
model: "kimi-k2-0905-preview",
|
||||||
});
|
});
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-1
@@ -187,7 +187,7 @@ describe("directive behavior", () => {
|
|||||||
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("lists allowlisted models on /model", async () => {
|
it("shows summary on /model", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
@@ -212,8 +212,10 @@ describe("directive behavior", () => {
|
|||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("Current: anthropic/claude-opus-4-5");
|
expect(text).toContain("Current: anthropic/claude-opus-4-5");
|
||||||
|
expect(text).toContain("Switch: /model <provider/model>");
|
||||||
expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
|
expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
|
||||||
expect(text).toContain("More: /model status");
|
expect(text).toContain("More: /model status");
|
||||||
|
expect(text).not.toContain("openai/gpt-4.1-mini");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-3
@@ -123,7 +123,7 @@ describe("trigger handling", () => {
|
|||||||
expect(normalized).not.toContain("image");
|
expect(normalized).not.toContain("image");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("moves /model list to /models", async () => {
|
it("aliases /model list to /models", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const cfg = makeCfg(home);
|
const cfg = makeCfg(home);
|
||||||
const res = await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
@@ -143,8 +143,8 @@ describe("trigger handling", () => {
|
|||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
const normalized = normalizeTestText(text ?? "");
|
const normalized = normalizeTestText(text ?? "");
|
||||||
expect(normalized).toContain("Model listing moved.");
|
expect(normalized).toContain("Providers:");
|
||||||
expect(normalized).toContain("Use: /models (providers) or /models <provider> (models)");
|
expect(normalized).toContain("Use: /models <provider>");
|
||||||
expect(normalized).toContain("Switch: /model <provider/model>");
|
expect(normalized).toContain("Switch: /model <provider/model>");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
resolveModelRefFromString,
|
resolveModelRefFromString,
|
||||||
} from "../../agents/model-selection.js";
|
} from "../../agents/model-selection.js";
|
||||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js";
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js";
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import type { ReplyPayload } from "../types.js";
|
import type { ReplyPayload } from "../types.js";
|
||||||
import type { CommandHandler } from "./commands-types.js";
|
import type { CommandHandler } from "./commands-types.js";
|
||||||
|
|
||||||
@@ -68,10 +69,11 @@ function parseModelsArgs(raw: string): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const handleModelsCommand: CommandHandler = async (params, allowTextCommands) => {
|
export async function resolveModelsCommandReply(params: {
|
||||||
if (!allowTextCommands) return null;
|
cfg: ClawdbotConfig;
|
||||||
|
commandBodyNormalized: string;
|
||||||
const body = params.command.commandBodyNormalized.trim();
|
}): Promise<ReplyPayload | null> {
|
||||||
|
const body = params.commandBodyNormalized.trim();
|
||||||
if (!body.startsWith("/models")) return null;
|
if (!body.startsWith("/models")) return null;
|
||||||
|
|
||||||
const argText = body.replace(/^\/models\b/i, "").trim();
|
const argText = body.replace(/^\/models\b/i, "").trim();
|
||||||
@@ -164,7 +166,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
|
|||||||
"Use: /models <provider>",
|
"Use: /models <provider>",
|
||||||
"Switch: /model <provider/model>",
|
"Switch: /model <provider/model>",
|
||||||
];
|
];
|
||||||
return { reply: { text: lines.join("\n") }, shouldContinue: false };
|
return { text: lines.join("\n") };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!byProvider.has(provider)) {
|
if (!byProvider.has(provider)) {
|
||||||
@@ -176,7 +178,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
|
|||||||
"",
|
"",
|
||||||
"Use: /models <provider>",
|
"Use: /models <provider>",
|
||||||
];
|
];
|
||||||
return { reply: { text: lines.join("\n") }, shouldContinue: false };
|
return { text: lines.join("\n") };
|
||||||
}
|
}
|
||||||
|
|
||||||
const models = [...(byProvider.get(provider) ?? new Set<string>())].sort();
|
const models = [...(byProvider.get(provider) ?? new Set<string>())].sort();
|
||||||
@@ -189,7 +191,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
|
|||||||
"Browse: /models",
|
"Browse: /models",
|
||||||
"Switch: /model <provider/model>",
|
"Switch: /model <provider/model>",
|
||||||
];
|
];
|
||||||
return { reply: { text: lines.join("\n") }, shouldContinue: false };
|
return { text: lines.join("\n") };
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectivePageSize = all ? total : pageSize;
|
const effectivePageSize = all ? total : pageSize;
|
||||||
@@ -203,7 +205,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
|
|||||||
`Try: /models ${provider} ${safePage}`,
|
`Try: /models ${provider} ${safePage}`,
|
||||||
`All: /models ${provider} all`,
|
`All: /models ${provider} all`,
|
||||||
];
|
];
|
||||||
return { reply: { text: lines.join("\n") }, shouldContinue: false };
|
return { text: lines.join("\n") };
|
||||||
}
|
}
|
||||||
|
|
||||||
const startIndex = (safePage - 1) * effectivePageSize;
|
const startIndex = (safePage - 1) * effectivePageSize;
|
||||||
@@ -226,5 +228,16 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload: ReplyPayload = { text: lines.join("\n") };
|
const payload: ReplyPayload = { text: lines.join("\n") };
|
||||||
return { reply: payload, shouldContinue: false };
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleModelsCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||||
|
if (!allowTextCommands) return null;
|
||||||
|
|
||||||
|
const reply = await resolveModelsCommandReply({
|
||||||
|
cfg: params.cfg,
|
||||||
|
commandBodyNormalized: params.command.commandBodyNormalized,
|
||||||
|
});
|
||||||
|
if (!reply) return null;
|
||||||
|
return { reply, shouldContinue: false };
|
||||||
};
|
};
|
||||||
@@ -36,7 +36,7 @@ describe("/model chat UX", () => {
|
|||||||
expect(reply?.text).toContain("Switch: /model <provider/model>");
|
expect(reply?.text).toContain("Switch: /model <provider/model>");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("suggests closest match for typos without switching", () => {
|
it("auto-applies closest match for typos", () => {
|
||||||
const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5");
|
const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5");
|
||||||
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
|
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
@@ -52,9 +52,11 @@ describe("/model chat UX", () => {
|
|||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(resolved.modelSelection).toBeUndefined();
|
expect(resolved.modelSelection).toEqual({
|
||||||
expect(resolved.errorText).toContain("Did you mean:");
|
provider: "anthropic",
|
||||||
expect(resolved.errorText).toContain("anthropic/claude-opus-4-5");
|
model: "claude-opus-4-5",
|
||||||
expect(resolved.errorText).toContain("Try: /model anthropic/claude-opus-4-5");
|
isDefault: true,
|
||||||
|
});
|
||||||
|
expect(resolved.errorText).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
resolveProviderEndpointLabel,
|
resolveProviderEndpointLabel,
|
||||||
} from "./directive-handling.model-picker.js";
|
} from "./directive-handling.model-picker.js";
|
||||||
import type { InlineDirectives } from "./directive-handling.parse.js";
|
import type { InlineDirectives } from "./directive-handling.parse.js";
|
||||||
|
import { resolveModelsCommandReply } from "./commands-models.js";
|
||||||
import { type ModelDirectiveSelection, resolveModelDirectiveSelection } from "./model-selection.js";
|
import { type ModelDirectiveSelection, resolveModelDirectiveSelection } from "./model-selection.js";
|
||||||
|
|
||||||
function buildModelPickerCatalog(params: {
|
function buildModelPickerCatalog(params: {
|
||||||
@@ -185,14 +186,11 @@ export async function maybeHandleModelDirectiveInfo(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (wantsLegacyList) {
|
if (wantsLegacyList) {
|
||||||
return {
|
const reply = await resolveModelsCommandReply({
|
||||||
text: [
|
cfg: params.cfg,
|
||||||
"Model listing moved.",
|
commandBodyNormalized: "/models",
|
||||||
"",
|
});
|
||||||
"Use: /models (providers) or /models <provider> (models)",
|
return reply ?? { text: "No models available." };
|
||||||
"Switch: /model <provider/model>",
|
|
||||||
].join("\n"),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wantsSummary) {
|
if (wantsSummary) {
|
||||||
@@ -340,42 +338,7 @@ export function resolveModelSelectionFromDirective(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (resolved.selection) {
|
if (resolved.selection) {
|
||||||
const suggestion = `${resolved.selection.provider}/${resolved.selection.model}`;
|
modelSelection = resolved.selection;
|
||||||
const rawHasSlash = raw.includes("/");
|
|
||||||
const shouldAutoSelect = (() => {
|
|
||||||
if (!rawHasSlash) return true;
|
|
||||||
const slash = raw.indexOf("/");
|
|
||||||
if (slash <= 0) return true;
|
|
||||||
const rawProvider = normalizeProviderId(raw.slice(0, slash));
|
|
||||||
const rawFragment = raw
|
|
||||||
.slice(slash + 1)
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
if (!rawFragment) return false;
|
|
||||||
const resolvedProvider = normalizeProviderId(resolved.selection.provider);
|
|
||||||
if (rawProvider !== resolvedProvider) return false;
|
|
||||||
const resolvedModel = resolved.selection.model.toLowerCase();
|
|
||||||
return (
|
|
||||||
resolvedModel.startsWith(rawFragment) ||
|
|
||||||
resolvedModel.includes(rawFragment) ||
|
|
||||||
rawFragment.startsWith(resolvedModel)
|
|
||||||
);
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (shouldAutoSelect) {
|
|
||||||
modelSelection = resolved.selection;
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
errorText: [
|
|
||||||
`Unrecognized model: ${raw}`,
|
|
||||||
"",
|
|
||||||
`Did you mean: ${suggestion}`,
|
|
||||||
`Try: /model ${suggestion}`,
|
|
||||||
"",
|
|
||||||
"Browse: /models or /models <provider>",
|
|
||||||
].join("\n"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"tools.exec.applyPatch.enabled": "Enable apply_patch",
|
"tools.exec.applyPatch.enabled": "Enable apply_patch",
|
||||||
"tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist",
|
"tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist",
|
||||||
"tools.exec.notifyOnExit": "Exec Notify On Exit",
|
"tools.exec.notifyOnExit": "Exec Notify On Exit",
|
||||||
|
"tools.exec.approvalRunningNoticeMs": "Exec Approval Running Notice (ms)",
|
||||||
"tools.exec.host": "Exec Host",
|
"tools.exec.host": "Exec Host",
|
||||||
"tools.exec.security": "Exec Security",
|
"tools.exec.security": "Exec Security",
|
||||||
"tools.exec.ask": "Exec Ask",
|
"tools.exec.ask": "Exec Ask",
|
||||||
|
|||||||
@@ -173,6 +173,8 @@ export type AgentDefaultsConfig = {
|
|||||||
};
|
};
|
||||||
/** Heartbeat model override (provider/model). */
|
/** Heartbeat model override (provider/model). */
|
||||||
model?: string;
|
model?: string;
|
||||||
|
/** Session key for heartbeat runs ("main" or explicit session key). */
|
||||||
|
session?: string;
|
||||||
/** Delivery target (last|whatsapp|telegram|discord|slack|msteams|signal|imessage|none). */
|
/** Delivery target (last|whatsapp|telegram|discord|slack|msteams|signal|imessage|none). */
|
||||||
target?:
|
target?:
|
||||||
| "last"
|
| "last"
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ export type ExecToolConfig = {
|
|||||||
backgroundMs?: number;
|
backgroundMs?: number;
|
||||||
/** Default timeout (seconds) before auto-killing exec commands. */
|
/** Default timeout (seconds) before auto-killing exec commands. */
|
||||||
timeoutSec?: number;
|
timeoutSec?: number;
|
||||||
|
/** Emit a running notice (ms) when approval-backed exec runs long (default: 10000, 0 = off). */
|
||||||
|
approvalRunningNoticeMs?: number;
|
||||||
/** How long to keep finished sessions in memory (ms). */
|
/** How long to keep finished sessions in memory (ms). */
|
||||||
cleanupMs?: number;
|
cleanupMs?: number;
|
||||||
/** Emit a system event and heartbeat when a backgrounded exec exits. */
|
/** Emit a system event and heartbeat when a backgrounded exec exits. */
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const HeartbeatSchema = z
|
|||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
|
session: z.string().optional(),
|
||||||
includeReasoning: z.boolean().optional(),
|
includeReasoning: z.boolean().optional(),
|
||||||
target: z
|
target: z
|
||||||
.union([
|
.union([
|
||||||
@@ -260,6 +261,7 @@ export const AgentToolsSchema = z
|
|||||||
safeBins: z.array(z.string()).optional(),
|
safeBins: z.array(z.string()).optional(),
|
||||||
backgroundMs: z.number().int().positive().optional(),
|
backgroundMs: z.number().int().positive().optional(),
|
||||||
timeoutSec: z.number().int().positive().optional(),
|
timeoutSec: z.number().int().positive().optional(),
|
||||||
|
approvalRunningNoticeMs: z.number().int().nonnegative().optional(),
|
||||||
cleanupMs: z.number().int().positive().optional(),
|
cleanupMs: z.number().int().positive().optional(),
|
||||||
notifyOnExit: z.boolean().optional(),
|
notifyOnExit: z.boolean().optional(),
|
||||||
applyPatch: z
|
applyPatch: z
|
||||||
|
|||||||
@@ -33,11 +33,15 @@ type PendingEntry = {
|
|||||||
export class ExecApprovalManager {
|
export class ExecApprovalManager {
|
||||||
private pending = new Map<string, PendingEntry>();
|
private pending = new Map<string, PendingEntry>();
|
||||||
|
|
||||||
create(request: ExecApprovalRequestPayload, timeoutMs: number): ExecApprovalRecord {
|
create(
|
||||||
|
request: ExecApprovalRequestPayload,
|
||||||
|
timeoutMs: number,
|
||||||
|
id?: string | null,
|
||||||
|
): ExecApprovalRecord {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const id = randomUUID();
|
const resolvedId = id && id.trim().length > 0 ? id.trim() : randomUUID();
|
||||||
const record: ExecApprovalRecord = {
|
const record: ExecApprovalRecord = {
|
||||||
id,
|
id: resolvedId,
|
||||||
request,
|
request,
|
||||||
createdAtMs: now,
|
createdAtMs: now,
|
||||||
expiresAtMs: now + timeoutMs,
|
expiresAtMs: now + timeoutMs,
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export const ExecApprovalsNodeSetParamsSchema = Type.Object(
|
|||||||
|
|
||||||
export const ExecApprovalRequestParamsSchema = Type.Object(
|
export const ExecApprovalRequestParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
|
id: Type.Optional(NonEmptyString),
|
||||||
command: NonEmptyString,
|
command: NonEmptyString,
|
||||||
cwd: Type.Optional(Type.String()),
|
cwd: Type.Optional(Type.String()),
|
||||||
host: Type.Optional(Type.String()),
|
host: Type.Optional(Type.String()),
|
||||||
|
|||||||
@@ -60,4 +60,120 @@ describe("exec approval handlers", () => {
|
|||||||
);
|
);
|
||||||
expect(broadcasts.some((entry) => entry.event === "exec.approval.resolved")).toBe(true);
|
expect(broadcasts.some((entry) => entry.event === "exec.approval.resolved")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts explicit approval ids", async () => {
|
||||||
|
const manager = new ExecApprovalManager();
|
||||||
|
const handlers = createExecApprovalHandlers(manager);
|
||||||
|
const broadcasts: Array<{ event: string; payload: unknown }> = [];
|
||||||
|
|
||||||
|
const respond = vi.fn();
|
||||||
|
const context = {
|
||||||
|
broadcast: (event: string, payload: unknown) => {
|
||||||
|
broadcasts.push({ event, payload });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestPromise = handlers["exec.approval.request"]({
|
||||||
|
params: {
|
||||||
|
id: "approval-123",
|
||||||
|
command: "echo ok",
|
||||||
|
cwd: "/tmp",
|
||||||
|
host: "gateway",
|
||||||
|
timeoutMs: 2000,
|
||||||
|
},
|
||||||
|
respond,
|
||||||
|
context: context as unknown as Parameters<
|
||||||
|
(typeof handlers)["exec.approval.request"]
|
||||||
|
>[0]["context"],
|
||||||
|
client: null,
|
||||||
|
req: { id: "req-1", type: "req", method: "exec.approval.request" },
|
||||||
|
isWebchatConnect: noop,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested");
|
||||||
|
const id = (requested?.payload as { id?: string })?.id ?? "";
|
||||||
|
expect(id).toBe("approval-123");
|
||||||
|
|
||||||
|
const resolveRespond = vi.fn();
|
||||||
|
await handlers["exec.approval.resolve"]({
|
||||||
|
params: { id, decision: "allow-once" },
|
||||||
|
respond: resolveRespond,
|
||||||
|
context: context as unknown as Parameters<
|
||||||
|
(typeof handlers)["exec.approval.resolve"]
|
||||||
|
>[0]["context"],
|
||||||
|
client: { connect: { client: { id: "cli", displayName: "CLI" } } },
|
||||||
|
req: { id: "req-2", type: "req", method: "exec.approval.resolve" },
|
||||||
|
isWebchatConnect: noop,
|
||||||
|
});
|
||||||
|
|
||||||
|
await requestPromise;
|
||||||
|
expect(respond).toHaveBeenCalledWith(
|
||||||
|
true,
|
||||||
|
expect.objectContaining({ id: "approval-123", decision: "allow-once" }),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate approval ids", async () => {
|
||||||
|
const manager = new ExecApprovalManager();
|
||||||
|
const handlers = createExecApprovalHandlers(manager);
|
||||||
|
const respondA = vi.fn();
|
||||||
|
const respondB = vi.fn();
|
||||||
|
const broadcasts: Array<{ event: string; payload: unknown }> = [];
|
||||||
|
const context = {
|
||||||
|
broadcast: (event: string, payload: unknown) => {
|
||||||
|
broadcasts.push({ event, payload });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestPromise = handlers["exec.approval.request"]({
|
||||||
|
params: {
|
||||||
|
id: "dup-1",
|
||||||
|
command: "echo ok",
|
||||||
|
},
|
||||||
|
respond: respondA,
|
||||||
|
context: context as unknown as Parameters<
|
||||||
|
(typeof handlers)["exec.approval.request"]
|
||||||
|
>[0]["context"],
|
||||||
|
client: null,
|
||||||
|
req: { id: "req-1", type: "req", method: "exec.approval.request" },
|
||||||
|
isWebchatConnect: noop,
|
||||||
|
});
|
||||||
|
|
||||||
|
await handlers["exec.approval.request"]({
|
||||||
|
params: {
|
||||||
|
id: "dup-1",
|
||||||
|
command: "echo again",
|
||||||
|
},
|
||||||
|
respond: respondB,
|
||||||
|
context: context as unknown as Parameters<
|
||||||
|
(typeof handlers)["exec.approval.request"]
|
||||||
|
>[0]["context"],
|
||||||
|
client: null,
|
||||||
|
req: { id: "req-2", type: "req", method: "exec.approval.request" },
|
||||||
|
isWebchatConnect: noop,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(respondB).toHaveBeenCalledWith(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
expect.objectContaining({ message: "approval id already pending" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested");
|
||||||
|
const id = (requested?.payload as { id?: string })?.id ?? "";
|
||||||
|
const resolveRespond = vi.fn();
|
||||||
|
await handlers["exec.approval.resolve"]({
|
||||||
|
params: { id, decision: "deny" },
|
||||||
|
respond: resolveRespond,
|
||||||
|
context: context as unknown as Parameters<
|
||||||
|
(typeof handlers)["exec.approval.resolve"]
|
||||||
|
>[0]["context"],
|
||||||
|
client: { connect: { client: { id: "cli", displayName: "CLI" } } },
|
||||||
|
req: { id: "req-3", type: "req", method: "exec.approval.resolve" },
|
||||||
|
isWebchatConnect: noop,
|
||||||
|
});
|
||||||
|
|
||||||
|
await requestPromise;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -26,6 +26,7 @@ export function createExecApprovalHandlers(manager: ExecApprovalManager): Gatewa
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const p = params as {
|
const p = params as {
|
||||||
|
id?: string;
|
||||||
command: string;
|
command: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
host?: string;
|
host?: string;
|
||||||
@@ -37,6 +38,15 @@ export function createExecApprovalHandlers(manager: ExecApprovalManager): Gatewa
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
};
|
};
|
||||||
const timeoutMs = typeof p.timeoutMs === "number" ? p.timeoutMs : 120_000;
|
const timeoutMs = typeof p.timeoutMs === "number" ? p.timeoutMs : 120_000;
|
||||||
|
const explicitId = typeof p.id === "string" && p.id.trim().length > 0 ? p.id.trim() : null;
|
||||||
|
if (explicitId && manager.getSnapshot(explicitId)) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(ErrorCodes.INVALID_REQUEST, "approval id already pending"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const request = {
|
const request = {
|
||||||
command: p.command,
|
command: p.command,
|
||||||
cwd: p.cwd ?? null,
|
cwd: p.cwd ?? null,
|
||||||
@@ -47,7 +57,7 @@ export function createExecApprovalHandlers(manager: ExecApprovalManager): Gatewa
|
|||||||
resolvedPath: p.resolvedPath ?? null,
|
resolvedPath: p.resolvedPath ?? null,
|
||||||
sessionKey: p.sessionKey ?? null,
|
sessionKey: p.sessionKey ?? null,
|
||||||
};
|
};
|
||||||
const record = manager.create(request, timeoutMs);
|
const record = manager.create(request, timeoutMs, explicitId);
|
||||||
context.broadcast(
|
context.broadcast(
|
||||||
"exec.approval.requested",
|
"exec.approval.requested",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -72,12 +72,13 @@ describe("exec approvals command resolution", () => {
|
|||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
const binDir = path.join(dir, "bin");
|
const binDir = path.join(dir, "bin");
|
||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
const exe = path.join(binDir, "rg");
|
const exeName = process.platform === "win32" ? "rg.exe" : "rg";
|
||||||
|
const exe = path.join(binDir, exeName);
|
||||||
fs.writeFileSync(exe, "");
|
fs.writeFileSync(exe, "");
|
||||||
fs.chmodSync(exe, 0o755);
|
fs.chmodSync(exe, 0o755);
|
||||||
const res = resolveCommandResolution("rg -n foo", undefined, { PATH: binDir });
|
const res = resolveCommandResolution("rg -n foo", undefined, { PATH: binDir });
|
||||||
expect(res?.resolvedPath).toBe(exe);
|
expect(res?.resolvedPath).toBe(exe);
|
||||||
expect(res?.executableName).toBe("rg");
|
expect(res?.executableName).toBe(exeName);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves relative paths against cwd", () => {
|
it("resolves relative paths against cwd", () => {
|
||||||
@@ -127,7 +128,8 @@ describe("exec approvals safe bins", () => {
|
|||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
const binDir = path.join(dir, "bin");
|
const binDir = path.join(dir, "bin");
|
||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
const exe = path.join(binDir, "jq");
|
const exeName = process.platform === "win32" ? "jq.exe" : "jq";
|
||||||
|
const exe = path.join(binDir, exeName);
|
||||||
fs.writeFileSync(exe, "");
|
fs.writeFileSync(exe, "");
|
||||||
fs.chmodSync(exe, 0o755);
|
fs.chmodSync(exe, 0o755);
|
||||||
const res = analyzeShellCommand({
|
const res = analyzeShellCommand({
|
||||||
@@ -150,7 +152,8 @@ describe("exec approvals safe bins", () => {
|
|||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
const binDir = path.join(dir, "bin");
|
const binDir = path.join(dir, "bin");
|
||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
const exe = path.join(binDir, "jq");
|
const exeName = process.platform === "win32" ? "jq.exe" : "jq";
|
||||||
|
const exe = path.join(binDir, exeName);
|
||||||
fs.writeFileSync(exe, "");
|
fs.writeFileSync(exe, "");
|
||||||
fs.chmodSync(exe, 0o755);
|
fs.chmodSync(exe, 0o755);
|
||||||
const file = path.join(dir, "secret.json");
|
const file = path.join(dir, "secret.json");
|
||||||
|
|||||||
@@ -660,7 +660,11 @@ export function isSafeBinUsage(params: {
|
|||||||
if (params.safeBins.size === 0) return false;
|
if (params.safeBins.size === 0) return false;
|
||||||
const resolution = params.resolution;
|
const resolution = params.resolution;
|
||||||
const execName = resolution?.executableName?.toLowerCase();
|
const execName = resolution?.executableName?.toLowerCase();
|
||||||
if (!execName || !params.safeBins.has(execName)) return false;
|
if (!execName) return false;
|
||||||
|
const matchesSafeBin =
|
||||||
|
params.safeBins.has(execName) ||
|
||||||
|
(process.platform === "win32" && params.safeBins.has(path.parse(execName).name));
|
||||||
|
if (!matchesSafeBin) return false;
|
||||||
if (!resolution?.resolvedPath) return false;
|
if (!resolution?.resolvedPath) return false;
|
||||||
const cwd = params.cwd ?? process.cwd();
|
const cwd = params.cwd ?? process.cwd();
|
||||||
const exists = params.fileExists ?? defaultFileExists;
|
const exists = params.fileExists ?? defaultFileExists;
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
heartbeat: {
|
heartbeat: {
|
||||||
every: "5m",
|
every: "5m",
|
||||||
target: "whatsapp",
|
target: "whatsapp",
|
||||||
to: "+1555",
|
|
||||||
ackMaxChars: 0,
|
ackMaxChars: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -58,6 +57,7 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
[sessionKey]: {
|
[sessionKey]: {
|
||||||
sessionId: "sid",
|
sessionId: "sid",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
|
lastChannel: "whatsapp",
|
||||||
lastProvider: "whatsapp",
|
lastProvider: "whatsapp",
|
||||||
lastTo: "+1555",
|
lastTo: "+1555",
|
||||||
},
|
},
|
||||||
@@ -102,7 +102,6 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
heartbeat: {
|
heartbeat: {
|
||||||
every: "5m",
|
every: "5m",
|
||||||
target: "whatsapp",
|
target: "whatsapp",
|
||||||
to: "+1555",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -118,6 +117,7 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
[sessionKey]: {
|
[sessionKey]: {
|
||||||
sessionId: "sid",
|
sessionId: "sid",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
|
lastChannel: "whatsapp",
|
||||||
lastProvider: "whatsapp",
|
lastProvider: "whatsapp",
|
||||||
lastTo: "+1555",
|
lastTo: "+1555",
|
||||||
},
|
},
|
||||||
@@ -164,7 +164,6 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
heartbeat: {
|
heartbeat: {
|
||||||
every: "5m",
|
every: "5m",
|
||||||
target: "whatsapp",
|
target: "whatsapp",
|
||||||
to: "+1555",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -180,6 +179,7 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
[sessionKey]: {
|
[sessionKey]: {
|
||||||
sessionId: "sid",
|
sessionId: "sid",
|
||||||
updatedAt: originalUpdatedAt,
|
updatedAt: originalUpdatedAt,
|
||||||
|
lastChannel: "whatsapp",
|
||||||
lastProvider: "whatsapp",
|
lastProvider: "whatsapp",
|
||||||
lastTo: "+1555",
|
lastTo: "+1555",
|
||||||
},
|
},
|
||||||
@@ -231,7 +231,7 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
heartbeat: { every: "5m", target: "whatsapp" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
@@ -246,6 +246,7 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
[sessionKey]: {
|
[sessionKey]: {
|
||||||
sessionId: "sid",
|
sessionId: "sid",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
|
lastChannel: "whatsapp",
|
||||||
lastProvider: "whatsapp",
|
lastProvider: "whatsapp",
|
||||||
lastTo: "+1555",
|
lastTo: "+1555",
|
||||||
},
|
},
|
||||||
@@ -291,7 +292,7 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
heartbeat: { every: "5m", target: "telegram", to: "123456" },
|
heartbeat: { every: "5m", target: "telegram" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
channels: { telegram: { botToken: "test-bot-token-123" } },
|
channels: { telegram: { botToken: "test-bot-token-123" } },
|
||||||
@@ -306,6 +307,7 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
[sessionKey]: {
|
[sessionKey]: {
|
||||||
sessionId: "sid",
|
sessionId: "sid",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
|
lastChannel: "telegram",
|
||||||
lastProvider: "telegram",
|
lastProvider: "telegram",
|
||||||
lastTo: "123456",
|
lastTo: "123456",
|
||||||
},
|
},
|
||||||
@@ -357,7 +359,7 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
heartbeat: { every: "5m", target: "telegram", to: "123456" },
|
heartbeat: { every: "5m", target: "telegram" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
channels: {
|
channels: {
|
||||||
@@ -378,6 +380,7 @@ describe("resolveHeartbeatIntervalMs", () => {
|
|||||||
[sessionKey]: {
|
[sessionKey]: {
|
||||||
sessionId: "sid",
|
sessionId: "sid",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
|
lastChannel: "telegram",
|
||||||
lastProvider: "telegram",
|
lastProvider: "telegram",
|
||||||
lastTo: "123456",
|
lastTo: "123456",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
resolveMainSessionKey,
|
resolveMainSessionKey,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
|
import { buildAgentPeerSessionKey } from "../routing/session-key.js";
|
||||||
import {
|
import {
|
||||||
isHeartbeatEnabledForAgent,
|
isHeartbeatEnabledForAgent,
|
||||||
resolveHeartbeatIntervalMs,
|
resolveHeartbeatIntervalMs,
|
||||||
@@ -332,7 +333,7 @@ describe("runHeartbeatOnce", () => {
|
|||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
heartbeat: { every: "5m", target: "whatsapp" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
@@ -395,7 +396,7 @@ describe("runHeartbeatOnce", () => {
|
|||||||
{ id: "main", default: true },
|
{ id: "main", default: true },
|
||||||
{
|
{
|
||||||
id: "ops",
|
id: "ops",
|
||||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555", prompt: "Ops check" },
|
heartbeat: { every: "5m", target: "whatsapp", prompt: "Ops check" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -451,6 +452,88 @@ describe("runHeartbeatOnce", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("runs heartbeats in the explicit session key when configured", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||||
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||||
|
try {
|
||||||
|
const groupId = "120363401234567890@g.us";
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
heartbeat: {
|
||||||
|
every: "5m",
|
||||||
|
target: "last",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
|
session: { store: storePath },
|
||||||
|
};
|
||||||
|
const mainSessionKey = resolveMainSessionKey(cfg);
|
||||||
|
const agentId = resolveAgentIdFromSessionKey(mainSessionKey);
|
||||||
|
const groupSessionKey = buildAgentPeerSessionKey({
|
||||||
|
agentId,
|
||||||
|
channel: "whatsapp",
|
||||||
|
peerKind: "group",
|
||||||
|
peerId: groupId,
|
||||||
|
});
|
||||||
|
if (cfg.agents?.defaults?.heartbeat) {
|
||||||
|
cfg.agents.defaults.heartbeat.session = groupSessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
storePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
[mainSessionKey]: {
|
||||||
|
sessionId: "sid-main",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
lastChannel: "whatsapp",
|
||||||
|
lastTo: "+1555",
|
||||||
|
},
|
||||||
|
[groupSessionKey]: {
|
||||||
|
sessionId: "sid-group",
|
||||||
|
updatedAt: Date.now() + 10_000,
|
||||||
|
lastChannel: "whatsapp",
|
||||||
|
lastTo: groupId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
replySpy.mockResolvedValue([{ text: "Group alert" }]);
|
||||||
|
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||||
|
messageId: "m1",
|
||||||
|
toJid: "jid",
|
||||||
|
});
|
||||||
|
|
||||||
|
await runHeartbeatOnce({
|
||||||
|
cfg,
|
||||||
|
deps: {
|
||||||
|
sendWhatsApp,
|
||||||
|
getQueueSize: () => 0,
|
||||||
|
nowMs: () => 0,
|
||||||
|
webAuthExists: async () => true,
|
||||||
|
hasActiveWebListener: () => true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sendWhatsApp).toHaveBeenCalledWith(groupId, "Group alert", expect.any(Object));
|
||||||
|
expect(replySpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ SessionKey: groupSessionKey }),
|
||||||
|
{ isHeartbeat: true },
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
replySpy.mockRestore();
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("suppresses duplicate heartbeat payloads within 24h", async () => {
|
it("suppresses duplicate heartbeat payloads within 24h", async () => {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||||
const storePath = path.join(tmpDir, "sessions.json");
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
@@ -459,7 +542,7 @@ describe("runHeartbeatOnce", () => {
|
|||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
heartbeat: { every: "5m", target: "whatsapp" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||||
@@ -517,7 +600,6 @@ describe("runHeartbeatOnce", () => {
|
|||||||
heartbeat: {
|
heartbeat: {
|
||||||
every: "5m",
|
every: "5m",
|
||||||
target: "whatsapp",
|
target: "whatsapp",
|
||||||
to: "+1555",
|
|
||||||
includeReasoning: true,
|
includeReasoning: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -534,6 +616,7 @@ describe("runHeartbeatOnce", () => {
|
|||||||
[sessionKey]: {
|
[sessionKey]: {
|
||||||
sessionId: "sid",
|
sessionId: "sid",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
|
lastChannel: "whatsapp",
|
||||||
lastProvider: "whatsapp",
|
lastProvider: "whatsapp",
|
||||||
lastTo: "+1555",
|
lastTo: "+1555",
|
||||||
},
|
},
|
||||||
@@ -588,7 +671,6 @@ describe("runHeartbeatOnce", () => {
|
|||||||
heartbeat: {
|
heartbeat: {
|
||||||
every: "5m",
|
every: "5m",
|
||||||
target: "whatsapp",
|
target: "whatsapp",
|
||||||
to: "+1555",
|
|
||||||
includeReasoning: true,
|
includeReasoning: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -605,6 +687,7 @@ describe("runHeartbeatOnce", () => {
|
|||||||
[sessionKey]: {
|
[sessionKey]: {
|
||||||
sessionId: "sid",
|
sessionId: "sid",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
|
lastChannel: "whatsapp",
|
||||||
lastProvider: "whatsapp",
|
lastProvider: "whatsapp",
|
||||||
lastTo: "+1555",
|
lastTo: "+1555",
|
||||||
},
|
},
|
||||||
@@ -672,6 +755,7 @@ describe("runHeartbeatOnce", () => {
|
|||||||
[sessionKey]: {
|
[sessionKey]: {
|
||||||
sessionId: "sid",
|
sessionId: "sid",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
|
lastChannel: "whatsapp",
|
||||||
lastProvider: "whatsapp",
|
lastProvider: "whatsapp",
|
||||||
lastTo: "+1555",
|
lastTo: "+1555",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import { parseDurationMs } from "../cli/parse-duration.js";
|
|||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
|
canonicalizeMainSessionAlias,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
|
resolveAgentIdFromSessionKey,
|
||||||
resolveAgentMainSessionKey,
|
resolveAgentMainSessionKey,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
@@ -27,7 +29,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js";
|
|||||||
import { getQueueSize } from "../process/command-queue.js";
|
import { getQueueSize } from "../process/command-queue.js";
|
||||||
import { CommandLane } from "../process/lanes.js";
|
import { CommandLane } from "../process/lanes.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
import { normalizeAgentId } from "../routing/session-key.js";
|
import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js";
|
||||||
import { emitHeartbeatEvent } from "./heartbeat-events.js";
|
import { emitHeartbeatEvent } from "./heartbeat-events.js";
|
||||||
import {
|
import {
|
||||||
type HeartbeatRunResult,
|
type HeartbeatRunResult,
|
||||||
@@ -286,17 +288,53 @@ function resolveHeartbeatAckMaxChars(cfg: ClawdbotConfig, heartbeat?: HeartbeatC
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveHeartbeatSession(cfg: ClawdbotConfig, agentId?: string) {
|
function resolveHeartbeatSession(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
agentId?: string,
|
||||||
|
heartbeat?: HeartbeatConfig,
|
||||||
|
) {
|
||||||
const sessionCfg = cfg.session;
|
const sessionCfg = cfg.session;
|
||||||
const scope = sessionCfg?.scope ?? "per-sender";
|
const scope = sessionCfg?.scope ?? "per-sender";
|
||||||
const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg));
|
const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg));
|
||||||
const sessionKey =
|
const mainSessionKey =
|
||||||
scope === "global" ? "global" : resolveAgentMainSessionKey({ cfg, agentId: resolvedAgentId });
|
scope === "global" ? "global" : resolveAgentMainSessionKey({ cfg, agentId: resolvedAgentId });
|
||||||
const storeAgentId = scope === "global" ? resolveDefaultAgentId(cfg) : resolvedAgentId;
|
const storeAgentId = scope === "global" ? resolveDefaultAgentId(cfg) : resolvedAgentId;
|
||||||
const storePath = resolveStorePath(sessionCfg?.store, { agentId: storeAgentId });
|
const storePath = resolveStorePath(sessionCfg?.store, { agentId: storeAgentId });
|
||||||
const store = loadSessionStore(storePath);
|
const store = loadSessionStore(storePath);
|
||||||
const entry = store[sessionKey];
|
const mainEntry = store[mainSessionKey];
|
||||||
return { sessionKey, storePath, store, entry };
|
|
||||||
|
if (scope === "global") {
|
||||||
|
return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = heartbeat?.session?.trim() ?? "";
|
||||||
|
if (!trimmed) {
|
||||||
|
return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = trimmed.toLowerCase();
|
||||||
|
if (normalized === "main" || normalized === "global") {
|
||||||
|
return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry };
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = toAgentStoreSessionKey({
|
||||||
|
agentId: resolvedAgentId,
|
||||||
|
requestKey: trimmed,
|
||||||
|
mainKey: cfg.session?.mainKey,
|
||||||
|
});
|
||||||
|
const canonical = canonicalizeMainSessionAlias({
|
||||||
|
cfg,
|
||||||
|
agentId: resolvedAgentId,
|
||||||
|
sessionKey: candidate,
|
||||||
|
});
|
||||||
|
if (canonical !== "global") {
|
||||||
|
const sessionAgentId = resolveAgentIdFromSessionKey(canonical);
|
||||||
|
if (sessionAgentId === normalizeAgentId(resolvedAgentId)) {
|
||||||
|
return { sessionKey: canonical, storePath, store, entry: store[canonical] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry };
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveHeartbeatReplyPayload(
|
function resolveHeartbeatReplyPayload(
|
||||||
@@ -427,7 +465,7 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
return { status: "skipped", reason: "requests-in-flight" };
|
return { status: "skipped", reason: "requests-in-flight" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId);
|
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat);
|
||||||
const previousUpdatedAt = entry?.updatedAt;
|
const previousUpdatedAt = entry?.updatedAt;
|
||||||
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
|
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
|
||||||
const lastChannel = delivery.lastChannel;
|
const lastChannel = delivery.lastChannel;
|
||||||
|
|||||||
+2
-12
@@ -57,6 +57,7 @@ type SystemRunParams = {
|
|||||||
sessionKey?: string | null;
|
sessionKey?: string | null;
|
||||||
approved?: boolean | null;
|
approved?: boolean | null;
|
||||||
approvalDecision?: string | null;
|
approvalDecision?: string | null;
|
||||||
|
runId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SystemWhichParams = {
|
type SystemWhichParams = {
|
||||||
@@ -583,7 +584,7 @@ async function handleInvoke(
|
|||||||
const ask = approvals.agent.ask;
|
const ask = approvals.agent.ask;
|
||||||
const autoAllowSkills = approvals.agent.autoAllowSkills;
|
const autoAllowSkills = approvals.agent.autoAllowSkills;
|
||||||
const sessionKey = params.sessionKey?.trim() || "node";
|
const sessionKey = params.sessionKey?.trim() || "node";
|
||||||
const runId = crypto.randomUUID();
|
const runId = params.runId?.trim() || crypto.randomUUID();
|
||||||
const env = sanitizeEnv(params.env ?? undefined);
|
const env = sanitizeEnv(params.env ?? undefined);
|
||||||
const analysis = rawCommand
|
const analysis = rawCommand
|
||||||
? analyzeShellCommand({ command: rawCommand, cwd: params.cwd ?? undefined, env })
|
? analyzeShellCommand({ command: rawCommand, cwd: params.cwd ?? undefined, env })
|
||||||
@@ -803,17 +804,6 @@ async function handleInvoke(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendNodeEvent(
|
|
||||||
client,
|
|
||||||
"exec.started",
|
|
||||||
buildExecEventPayload({
|
|
||||||
sessionKey,
|
|
||||||
runId,
|
|
||||||
host: "node",
|
|
||||||
command: cmdText,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await runCommand(
|
const result = await runCommand(
|
||||||
argv,
|
argv,
|
||||||
params.cwd?.trim() || undefined,
|
params.cwd?.trim() || undefined,
|
||||||
|
|||||||
Reference in new issue
Block a user