feat: add agent targeting + reply overrides
This commit is contained in:
@@ -6,13 +6,11 @@ Docs: https://docs.clawd.bot
|
|||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
|
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
|
||||||
- Agents: make inbound message envelopes configurable (timezone/timestamp/elapsed) and surface elapsed gaps; time design is actively being explored. See https://docs.clawd.bot/date-time. (#1150) — thanks @shiv19.
|
- CLI: show Telegram bot username in channel status (probe/runtime).
|
||||||
- TUI: add animated waiting shimmer status in the terminal UI. (#1196) — thanks @vignesh07.
|
- CLI: add agent targeting and reply routing overrides for `clawdbot agent`. (#1165)
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
|
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
|
||||||
- macOS: load menu session previews asynchronously so items populate while the menu is open.
|
|
||||||
- macOS: use label colors for session preview text so previews render in menu subviews.
|
|
||||||
|
|
||||||
## 2026.1.18-4
|
## 2026.1.18-4
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ read_when:
|
|||||||
# `clawdbot agent`
|
# `clawdbot agent`
|
||||||
|
|
||||||
Run an agent turn via the Gateway (use `--local` for embedded).
|
Run an agent turn via the Gateway (use `--local` for embedded).
|
||||||
|
Use `--agent <id>` to target a configured agent directly.
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Agent send tool: [Agent send](/tools/agent-send)
|
- Agent send tool: [Agent send](/tools/agent-send)
|
||||||
@@ -15,6 +16,7 @@ Related:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot agent --to +15555550123 --message "status update" --deliver
|
clawdbot agent --to +15555550123 --message "status update" --deliver
|
||||||
|
clawdbot agent --agent ops --message "Summarize logs"
|
||||||
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
|
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
|
||||||
|
clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,15 @@ runtime on the current machine.
|
|||||||
- Required: `--message <text>`
|
- Required: `--message <text>`
|
||||||
- Session selection:
|
- Session selection:
|
||||||
- `--to <dest>` derives the session key (group/channel targets preserve isolation; direct chats collapse to `main`), **or**
|
- `--to <dest>` derives the session key (group/channel targets preserve isolation; direct chats collapse to `main`), **or**
|
||||||
- `--session-id <id>` reuses an existing session by id
|
- `--session-id <id>` reuses an existing session by id, **or**
|
||||||
|
- `--agent <id>` targets a configured agent directly (uses that agent's `main` session key)
|
||||||
- Runs the same embedded agent runtime as normal inbound replies.
|
- Runs the same embedded agent runtime as normal inbound replies.
|
||||||
- Thinking/verbose flags persist into the session store.
|
- Thinking/verbose flags persist into the session store.
|
||||||
- Output:
|
- Output:
|
||||||
- default: prints reply text (plus `MEDIA:<url>` lines)
|
- default: prints reply text (plus `MEDIA:<url>` lines)
|
||||||
- `--json`: prints structured payload + metadata
|
- `--json`: prints structured payload + metadata
|
||||||
- Optional delivery back to a channel with `--deliver` + `--channel` (target formats match `clawdbot message --target`).
|
- Optional delivery back to a channel with `--deliver` + `--channel` (target formats match `clawdbot message --target`).
|
||||||
|
- Use `--reply-channel`/`--reply-to`/`--reply-account` to override delivery without changing the session.
|
||||||
|
|
||||||
If the Gateway is unreachable, the CLI **falls back** to the embedded local run.
|
If the Gateway is unreachable, the CLI **falls back** to the embedded local run.
|
||||||
|
|
||||||
@@ -28,16 +30,21 @@ If the Gateway is unreachable, the CLI **falls back** to the embedded local run.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot agent --to +15555550123 --message "status update"
|
clawdbot agent --to +15555550123 --message "status update"
|
||||||
|
clawdbot agent --agent ops --message "Summarize logs"
|
||||||
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
|
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
|
||||||
clawdbot agent --to +15555550123 --message "Trace logs" --verbose on --json
|
clawdbot agent --to +15555550123 --message "Trace logs" --verbose on --json
|
||||||
clawdbot agent --to +15555550123 --message "Summon reply" --deliver
|
clawdbot agent --to +15555550123 --message "Summon reply" --deliver
|
||||||
|
clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Flags
|
## Flags
|
||||||
|
|
||||||
- `--local`: run locally (requires model provider API keys in your shell)
|
- `--local`: run locally (requires model provider API keys in your shell)
|
||||||
- `--deliver`: send the reply to the chosen channel (requires `--to`)
|
- `--deliver`: send the reply to the chosen channel
|
||||||
- `--channel`: `whatsapp|telegram|discord|slack|signal|imessage` (default: `whatsapp`)
|
- `--channel`: delivery channel (`whatsapp|telegram|discord|slack|signal|imessage`, default: `whatsapp`)
|
||||||
|
- `--reply-to`: delivery target override
|
||||||
|
- `--reply-channel`: delivery channel override
|
||||||
|
- `--reply-account`: delivery account id override
|
||||||
- `--thinking <off|minimal|low|medium|high|xhigh>`: persist thinking level (GPT-5.2 + Codex models only)
|
- `--thinking <off|minimal|low|medium|high|xhigh>`: persist thinking level (GPT-5.2 + Codex models only)
|
||||||
- `--verbose <on|full|off>`: persist verbose level
|
- `--verbose <on|full|off>`: persist verbose level
|
||||||
- `--timeout <seconds>`: override agent timeout
|
- `--timeout <seconds>`: override agent timeout
|
||||||
|
|||||||
@@ -17,12 +17,16 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti
|
|||||||
.requiredOption("-m, --message <text>", "Message body for the agent")
|
.requiredOption("-m, --message <text>", "Message body for the agent")
|
||||||
.option("-t, --to <number>", "Recipient number in E.164 used to derive the session key")
|
.option("-t, --to <number>", "Recipient number in E.164 used to derive the session key")
|
||||||
.option("--session-id <id>", "Use an explicit session id")
|
.option("--session-id <id>", "Use an explicit session id")
|
||||||
|
.option("--agent <id>", "Agent id (overrides routing bindings)")
|
||||||
.option("--thinking <level>", "Thinking level: off | minimal | low | medium | high")
|
.option("--thinking <level>", "Thinking level: off | minimal | low | medium | high")
|
||||||
.option("--verbose <on|off>", "Persist agent verbose level for the session")
|
.option("--verbose <on|off>", "Persist agent verbose level for the session")
|
||||||
.option(
|
.option(
|
||||||
"--channel <channel>",
|
"--channel <channel>",
|
||||||
`Delivery channel: ${args.agentChannelOptions} (default: ${DEFAULT_CHAT_CHANNEL})`,
|
`Delivery channel: ${args.agentChannelOptions} (default: ${DEFAULT_CHAT_CHANNEL})`,
|
||||||
)
|
)
|
||||||
|
.option("--reply-to <target>", "Delivery target override (separate from session routing)")
|
||||||
|
.option("--reply-channel <channel>", "Delivery channel override (separate from routing)")
|
||||||
|
.option("--reply-account <id>", "Delivery account id override")
|
||||||
.option(
|
.option(
|
||||||
"--local",
|
"--local",
|
||||||
"Run the embedded agent locally (requires model provider API keys in your shell)",
|
"Run the embedded agent locally (requires model provider API keys in your shell)",
|
||||||
@@ -30,7 +34,7 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti
|
|||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--deliver",
|
"--deliver",
|
||||||
"Send the agent's reply back to the selected channel (requires --to)",
|
"Send the agent's reply back to the selected channel",
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
.option("--json", "Output result as JSON", false)
|
.option("--json", "Output result as JSON", false)
|
||||||
@@ -44,9 +48,11 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti
|
|||||||
`
|
`
|
||||||
Examples:
|
Examples:
|
||||||
clawdbot agent --to +15555550123 --message "status update"
|
clawdbot agent --to +15555550123 --message "status update"
|
||||||
|
clawdbot agent --agent ops --message "Summarize logs"
|
||||||
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
|
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
|
||||||
clawdbot agent --to +15555550123 --message "Trace logs" --verbose on --json
|
clawdbot agent --to +15555550123 --message "Trace logs" --verbose on --json
|
||||||
clawdbot agent --to +15555550123 --message "Summon reply" --deliver
|
clawdbot agent --to +15555550123 --message "Summon reply" --deliver
|
||||||
|
clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
|
||||||
|
|
||||||
${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent")}`,
|
${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent")}`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { DEFAULT_CHAT_CHANNEL } from "../channels/registry.js";
|
|||||||
import type { CliDeps } from "../cli/deps.js";
|
import type { CliDeps } from "../cli/deps.js";
|
||||||
import { withProgress } from "../cli/progress.js";
|
import { withProgress } from "../cli/progress.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { loadSessionStore, resolveSessionKey, resolveStorePath } from "../config/sessions.js";
|
import { resolveSessionKeyForRequest } from "./agent/session.js";
|
||||||
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
||||||
import { normalizeMainKey } from "../routing/session-key.js";
|
import { listAgentIds } from "../agents/agent-scope.js";
|
||||||
|
import { normalizeAgentId } from "../routing/session-key.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import {
|
import {
|
||||||
GATEWAY_CLIENT_MODES,
|
GATEWAY_CLIENT_MODES,
|
||||||
@@ -31,6 +32,7 @@ type GatewayAgentResponse = {
|
|||||||
|
|
||||||
export type AgentCliOpts = {
|
export type AgentCliOpts = {
|
||||||
message: string;
|
message: string;
|
||||||
|
agent?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
@@ -39,6 +41,9 @@ export type AgentCliOpts = {
|
|||||||
timeout?: string;
|
timeout?: string;
|
||||||
deliver?: boolean;
|
deliver?: boolean;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
|
replyTo?: string;
|
||||||
|
replyChannel?: string;
|
||||||
|
replyAccount?: string;
|
||||||
bestEffortDeliver?: boolean;
|
bestEffortDeliver?: boolean;
|
||||||
lane?: string;
|
lane?: string;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
@@ -46,27 +51,6 @@ export type AgentCliOpts = {
|
|||||||
local?: boolean;
|
local?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveGatewaySessionKey(opts: {
|
|
||||||
cfg: ReturnType<typeof loadConfig>;
|
|
||||||
to?: string;
|
|
||||||
sessionId?: string;
|
|
||||||
}): string | undefined {
|
|
||||||
const sessionCfg = opts.cfg.session;
|
|
||||||
const scope = sessionCfg?.scope ?? "per-sender";
|
|
||||||
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
|
|
||||||
const storePath = resolveStorePath(sessionCfg?.store);
|
|
||||||
const store = loadSessionStore(storePath);
|
|
||||||
|
|
||||||
const ctx = opts.to?.trim() ? ({ From: opts.to } as { From: string }) : null;
|
|
||||||
let sessionKey: string | undefined = ctx ? resolveSessionKey(scope, ctx, mainKey) : undefined;
|
|
||||||
|
|
||||||
if (opts.sessionId && (!sessionKey || store[sessionKey]?.sessionId !== opts.sessionId)) {
|
|
||||||
const foundKey = Object.keys(store).find((key) => store[key]?.sessionId === opts.sessionId);
|
|
||||||
if (foundKey) sessionKey = foundKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sessionKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTimeoutSeconds(opts: { cfg: ReturnType<typeof loadConfig>; timeout?: string }) {
|
function parseTimeoutSeconds(opts: { cfg: ReturnType<typeof loadConfig>; timeout?: string }) {
|
||||||
const raw =
|
const raw =
|
||||||
@@ -98,19 +82,30 @@ function formatPayloadForLog(payload: {
|
|||||||
export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: RuntimeEnv) {
|
export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: RuntimeEnv) {
|
||||||
const body = (opts.message ?? "").trim();
|
const body = (opts.message ?? "").trim();
|
||||||
if (!body) throw new Error("Message (--message) is required");
|
if (!body) throw new Error("Message (--message) is required");
|
||||||
if (!opts.to && !opts.sessionId) {
|
if (!opts.to && !opts.sessionId && !opts.agent) {
|
||||||
throw new Error("Pass --to <E.164> or --session-id to choose a session");
|
throw new Error("Pass --to <E.164>, --session-id, or --agent to choose a session");
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
|
const agentIdRaw = opts.agent?.trim();
|
||||||
|
const agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : undefined;
|
||||||
|
if (agentId) {
|
||||||
|
const knownAgents = listAgentIds(cfg);
|
||||||
|
if (!knownAgents.includes(agentId)) {
|
||||||
|
throw new Error(
|
||||||
|
`Unknown agent id "${agentIdRaw}". Use "clawdbot agents list" to see configured agents.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
const timeoutSeconds = parseTimeoutSeconds({ cfg, timeout: opts.timeout });
|
const timeoutSeconds = parseTimeoutSeconds({ cfg, timeout: opts.timeout });
|
||||||
const gatewayTimeoutMs = Math.max(10_000, (timeoutSeconds + 30) * 1000);
|
const gatewayTimeoutMs = Math.max(10_000, (timeoutSeconds + 30) * 1000);
|
||||||
|
|
||||||
const sessionKey = resolveGatewaySessionKey({
|
const sessionKey = resolveSessionKeyForRequest({
|
||||||
cfg,
|
cfg,
|
||||||
|
agentId,
|
||||||
to: opts.to,
|
to: opts.to,
|
||||||
sessionId: opts.sessionId,
|
sessionId: opts.sessionId,
|
||||||
});
|
}).sessionKey;
|
||||||
|
|
||||||
const channel = normalizeMessageChannel(opts.channel) ?? DEFAULT_CHAT_CHANNEL;
|
const channel = normalizeMessageChannel(opts.channel) ?? DEFAULT_CHAT_CHANNEL;
|
||||||
const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey();
|
const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey();
|
||||||
@@ -126,12 +121,16 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim
|
|||||||
method: "agent",
|
method: "agent",
|
||||||
params: {
|
params: {
|
||||||
message: body,
|
message: body,
|
||||||
|
agentId,
|
||||||
to: opts.to,
|
to: opts.to,
|
||||||
|
replyTo: opts.replyTo,
|
||||||
sessionId: opts.sessionId,
|
sessionId: opts.sessionId,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
thinking: opts.thinking,
|
thinking: opts.thinking,
|
||||||
deliver: Boolean(opts.deliver),
|
deliver: Boolean(opts.deliver),
|
||||||
channel,
|
channel,
|
||||||
|
replyChannel: opts.replyChannel,
|
||||||
|
replyAccountId: opts.replyAccount,
|
||||||
timeout: timeoutSeconds,
|
timeout: timeoutSeconds,
|
||||||
lane: opts.lane,
|
lane: opts.lane,
|
||||||
extraSystemPrompt: opts.extraSystemPrompt,
|
extraSystemPrompt: opts.extraSystemPrompt,
|
||||||
@@ -166,14 +165,19 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function agentCliCommand(opts: AgentCliOpts, runtime: RuntimeEnv, deps?: CliDeps) {
|
export async function agentCliCommand(opts: AgentCliOpts, runtime: RuntimeEnv, deps?: CliDeps) {
|
||||||
|
const localOpts = {
|
||||||
|
...opts,
|
||||||
|
agentId: opts.agent,
|
||||||
|
replyAccountId: opts.replyAccount,
|
||||||
|
};
|
||||||
if (opts.local === true) {
|
if (opts.local === true) {
|
||||||
return await agentCommand(opts, runtime, deps);
|
return await agentCommand(localOpts, runtime, deps);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await agentViaGatewayCommand(opts, runtime);
|
return await agentViaGatewayCommand(opts, runtime);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error?.(`Gateway agent failed; falling back to embedded: ${String(err)}`);
|
runtime.error?.(`Gateway agent failed; falling back to embedded: ${String(err)}`);
|
||||||
return await agentCommand(opts, runtime, deps);
|
return await agentCommand(localOpts, runtime, deps);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,6 +220,46 @@ describe("deliverAgentCommandResult", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses reply overrides for delivery routing", async () => {
|
||||||
|
const cfg = {} as ClawdbotConfig;
|
||||||
|
const deps = {} as CliDeps;
|
||||||
|
const runtime = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
} as unknown as RuntimeEnv;
|
||||||
|
const sessionEntry = {
|
||||||
|
lastChannel: "telegram",
|
||||||
|
lastTo: "123",
|
||||||
|
lastAccountId: "legacy",
|
||||||
|
} as SessionEntry;
|
||||||
|
const result = {
|
||||||
|
payloads: [{ text: "hi" }],
|
||||||
|
meta: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { deliverAgentCommandResult } = await import("./agent/delivery.js");
|
||||||
|
await deliverAgentCommandResult({
|
||||||
|
cfg,
|
||||||
|
deps,
|
||||||
|
runtime,
|
||||||
|
opts: {
|
||||||
|
message: "hello",
|
||||||
|
deliver: true,
|
||||||
|
to: "+15551234567",
|
||||||
|
replyTo: "#reports",
|
||||||
|
replyChannel: "slack",
|
||||||
|
replyAccountId: "ops",
|
||||||
|
},
|
||||||
|
sessionEntry,
|
||||||
|
result,
|
||||||
|
payloads: result.payloads,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.resolveOutboundTarget).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ channel: "slack", to: "#reports", accountId: "ops" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("prefixes nested agent outputs with context", async () => {
|
it("prefixes nested agent outputs with context", async () => {
|
||||||
const cfg = {} as ClawdbotConfig;
|
const cfg = {} as ClawdbotConfig;
|
||||||
const deps = {} as CliDeps;
|
const deps = {} as CliDeps;
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ function mockConfig(
|
|||||||
storePath: string,
|
storePath: string,
|
||||||
agentOverrides?: Partial<NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>>,
|
agentOverrides?: Partial<NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>>,
|
||||||
telegramOverrides?: Partial<NonNullable<ClawdbotConfig["telegram"]>>,
|
telegramOverrides?: Partial<NonNullable<ClawdbotConfig["telegram"]>>,
|
||||||
|
agentsList?: Array<{ id: string; default?: boolean }>,
|
||||||
) {
|
) {
|
||||||
configSpy.mockReturnValue({
|
configSpy.mockReturnValue({
|
||||||
agents: {
|
agents: {
|
||||||
@@ -54,6 +55,7 @@ function mockConfig(
|
|||||||
workspace: path.join(home, "clawd"),
|
workspace: path.join(home, "clawd"),
|
||||||
...agentOverrides,
|
...agentOverrides,
|
||||||
},
|
},
|
||||||
|
list: agentsList,
|
||||||
},
|
},
|
||||||
session: { store: storePath, mainKey: "main" },
|
session: { store: storePath, mainKey: "main" },
|
||||||
telegram: telegramOverrides ? { ...telegramOverrides } : undefined,
|
telegram: telegramOverrides ? { ...telegramOverrides } : undefined,
|
||||||
@@ -195,6 +197,30 @@ describe("agentCommand", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("derives session key from --agent when no routing target is provided", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const store = path.join(home, "sessions.json");
|
||||||
|
mockConfig(home, store, undefined, undefined, [{ id: "ops" }]);
|
||||||
|
|
||||||
|
await agentCommand({ message: "hi", agentId: "ops" }, runtime);
|
||||||
|
|
||||||
|
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||||
|
expect(callArgs?.sessionKey).toBe("agent:ops:main");
|
||||||
|
expect(callArgs?.sessionFile).toContain(`${path.sep}agents${path.sep}ops${path.sep}sessions`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown agent overrides", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const store = path.join(home, "sessions.json");
|
||||||
|
mockConfig(home, store);
|
||||||
|
|
||||||
|
await expect(agentCommand({ message: "hi", agentId: "ghost" }, runtime)).rejects.toThrow(
|
||||||
|
'Unknown agent id "ghost"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults thinking to low for reasoning-capable models", async () => {
|
it("defaults thinking to low for reasoning-capable models", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const store = path.join(home, "sessions.json");
|
const store = path.join(home, "sessions.json");
|
||||||
@@ -296,4 +322,27 @@ describe("agentCommand", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses reply channel as the message channel context", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const store = path.join(home, "sessions.json");
|
||||||
|
mockConfig(home, store, undefined, undefined, [{ id: "ops" }]);
|
||||||
|
|
||||||
|
await agentCommand({ message: "hi", agentId: "ops", replyChannel: "slack" }, runtime);
|
||||||
|
|
||||||
|
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||||
|
expect(callArgs?.messageChannel).toBe("slack");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs output when delivery is disabled", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const store = path.join(home, "sessions.json");
|
||||||
|
mockConfig(home, store, undefined, undefined, [{ id: "ops" }]);
|
||||||
|
|
||||||
|
await agentCommand({ message: "hi", agentId: "ops" }, runtime);
|
||||||
|
|
||||||
|
expect(runtime.log).toHaveBeenCalledWith("ok");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
listAgentIds,
|
||||||
resolveAgentDir,
|
resolveAgentDir,
|
||||||
resolveAgentModelFallbacksOverride,
|
resolveAgentModelFallbacksOverride,
|
||||||
resolveAgentModelPrimary,
|
resolveAgentModelPrimary,
|
||||||
@@ -53,6 +54,7 @@ import { deliverAgentCommandResult } from "./agent/delivery.js";
|
|||||||
import { resolveSession } from "./agent/session.js";
|
import { resolveSession } from "./agent/session.js";
|
||||||
import { updateSessionStoreAfterAgentRun } from "./agent/session-store.js";
|
import { updateSessionStoreAfterAgentRun } from "./agent/session-store.js";
|
||||||
import type { AgentCommandOpts } from "./agent/types.js";
|
import type { AgentCommandOpts } from "./agent/types.js";
|
||||||
|
import { normalizeAgentId } from "../routing/session-key.js";
|
||||||
|
|
||||||
export async function agentCommand(
|
export async function agentCommand(
|
||||||
opts: AgentCommandOpts,
|
opts: AgentCommandOpts,
|
||||||
@@ -61,13 +63,31 @@ export async function agentCommand(
|
|||||||
) {
|
) {
|
||||||
const body = (opts.message ?? "").trim();
|
const body = (opts.message ?? "").trim();
|
||||||
if (!body) throw new Error("Message (--message) is required");
|
if (!body) throw new Error("Message (--message) is required");
|
||||||
if (!opts.to && !opts.sessionId && !opts.sessionKey) {
|
if (!opts.to && !opts.sessionId && !opts.sessionKey && !opts.agentId) {
|
||||||
throw new Error("Pass --to <E.164> or --session-id to choose a session");
|
throw new Error("Pass --to <E.164>, --session-id, or --agent to choose a session");
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
|
const agentIdOverrideRaw = opts.agentId?.trim();
|
||||||
|
const agentIdOverride = agentIdOverrideRaw ? normalizeAgentId(agentIdOverrideRaw) : undefined;
|
||||||
|
if (agentIdOverride) {
|
||||||
|
const knownAgents = listAgentIds(cfg);
|
||||||
|
if (!knownAgents.includes(agentIdOverride)) {
|
||||||
|
throw new Error(
|
||||||
|
`Unknown agent id "${agentIdOverrideRaw}". Use "clawdbot agents list" to see configured agents.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (agentIdOverride && opts.sessionKey) {
|
||||||
|
const sessionAgentId = resolveAgentIdFromSessionKey(opts.sessionKey);
|
||||||
|
if (sessionAgentId !== agentIdOverride) {
|
||||||
|
throw new Error(
|
||||||
|
`Agent id "${agentIdOverrideRaw}" does not match session key agent "${sessionAgentId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
const agentCfg = cfg.agents?.defaults;
|
const agentCfg = cfg.agents?.defaults;
|
||||||
const sessionAgentId = resolveAgentIdFromSessionKey(opts.sessionKey?.trim());
|
const sessionAgentId = agentIdOverride ?? resolveAgentIdFromSessionKey(opts.sessionKey?.trim());
|
||||||
const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, sessionAgentId);
|
const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, sessionAgentId);
|
||||||
const agentDir = resolveAgentDir(cfg, sessionAgentId);
|
const agentDir = resolveAgentDir(cfg, sessionAgentId);
|
||||||
const workspace = await ensureAgentWorkspace({
|
const workspace = await ensureAgentWorkspace({
|
||||||
@@ -114,6 +134,7 @@ export async function agentCommand(
|
|||||||
to: opts.to,
|
to: opts.to,
|
||||||
sessionId: opts.sessionId,
|
sessionId: opts.sessionId,
|
||||||
sessionKey: opts.sessionKey,
|
sessionKey: opts.sessionKey,
|
||||||
|
agentId: agentIdOverride,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -346,7 +367,10 @@ export async function agentCommand(
|
|||||||
let fallbackProvider = provider;
|
let fallbackProvider = provider;
|
||||||
let fallbackModel = model;
|
let fallbackModel = model;
|
||||||
try {
|
try {
|
||||||
const messageChannel = resolveMessageChannel(opts.messageChannel, opts.channel);
|
const messageChannel = resolveMessageChannel(
|
||||||
|
opts.messageChannel,
|
||||||
|
opts.replyChannel ?? opts.channel,
|
||||||
|
);
|
||||||
const fallbackResult = await runWithModelFallback({
|
const fallbackResult = await runWithModelFallback({
|
||||||
cfg,
|
cfg,
|
||||||
provider,
|
provider,
|
||||||
|
|||||||
@@ -59,9 +59,9 @@ export async function deliverAgentCommandResult(params: {
|
|||||||
const bestEffortDeliver = opts.bestEffortDeliver === true;
|
const bestEffortDeliver = opts.bestEffortDeliver === true;
|
||||||
const deliveryPlan = resolveAgentDeliveryPlan({
|
const deliveryPlan = resolveAgentDeliveryPlan({
|
||||||
sessionEntry,
|
sessionEntry,
|
||||||
requestedChannel: opts.channel,
|
requestedChannel: opts.replyChannel ?? opts.channel,
|
||||||
explicitTo: opts.to,
|
explicitTo: opts.replyTo ?? opts.to,
|
||||||
accountId: opts.accountId,
|
accountId: opts.replyAccountId ?? opts.accountId,
|
||||||
wantsDelivery: deliver,
|
wantsDelivery: deliver,
|
||||||
});
|
});
|
||||||
const deliveryChannel = deliveryPlan.resolvedChannel;
|
const deliveryChannel = deliveryPlan.resolvedChannel;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
evaluateSessionFreshness,
|
evaluateSessionFreshness,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
resolveAgentIdFromSessionKey,
|
resolveAgentIdFromSessionKey,
|
||||||
|
resolveExplicitAgentSessionKey,
|
||||||
resolveSessionResetPolicy,
|
resolveSessionResetPolicy,
|
||||||
resolveSessionResetType,
|
resolveSessionResetType,
|
||||||
resolveSessionKey,
|
resolveSessionKey,
|
||||||
@@ -31,43 +32,72 @@ export type SessionResolution = {
|
|||||||
persistedVerbose?: VerboseLevel;
|
persistedVerbose?: VerboseLevel;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resolveSession(opts: {
|
type SessionKeyResolution = {
|
||||||
|
sessionKey?: string;
|
||||||
|
sessionStore: Record<string, SessionEntry>;
|
||||||
|
storePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveSessionKeyForRequest(opts: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
to?: string;
|
to?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
}): SessionResolution {
|
agentId?: string;
|
||||||
|
}): SessionKeyResolution {
|
||||||
const sessionCfg = opts.cfg.session;
|
const sessionCfg = opts.cfg.session;
|
||||||
const scope = sessionCfg?.scope ?? "per-sender";
|
const scope = sessionCfg?.scope ?? "per-sender";
|
||||||
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
|
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
|
||||||
const explicitSessionKey = opts.sessionKey?.trim();
|
const explicitSessionKey =
|
||||||
|
opts.sessionKey?.trim() ||
|
||||||
|
resolveExplicitAgentSessionKey({
|
||||||
|
cfg: opts.cfg,
|
||||||
|
agentId: opts.agentId,
|
||||||
|
});
|
||||||
const storeAgentId = resolveAgentIdFromSessionKey(explicitSessionKey);
|
const storeAgentId = resolveAgentIdFromSessionKey(explicitSessionKey);
|
||||||
const storePath = resolveStorePath(sessionCfg?.store, {
|
const storePath = resolveStorePath(sessionCfg?.store, {
|
||||||
agentId: storeAgentId,
|
agentId: storeAgentId,
|
||||||
});
|
});
|
||||||
const sessionStore = loadSessionStore(storePath);
|
const sessionStore = loadSessionStore(storePath);
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
const ctx: MsgContext | undefined = opts.to?.trim() ? { From: opts.to } : undefined;
|
const ctx: MsgContext | undefined = opts.to?.trim() ? { From: opts.to } : undefined;
|
||||||
let sessionKey: string | undefined =
|
let sessionKey: string | undefined =
|
||||||
explicitSessionKey ?? (ctx ? resolveSessionKey(scope, ctx, mainKey) : undefined);
|
explicitSessionKey ?? (ctx ? resolveSessionKey(scope, ctx, mainKey) : undefined);
|
||||||
let sessionEntry = sessionKey ? sessionStore[sessionKey] : undefined;
|
|
||||||
|
|
||||||
// If a session id was provided, prefer to re-use its entry (by id) even when no key was derived.
|
// If a session id was provided, prefer to re-use its entry (by id) even when no key was derived.
|
||||||
if (
|
if (
|
||||||
!explicitSessionKey &&
|
!explicitSessionKey &&
|
||||||
opts.sessionId &&
|
opts.sessionId &&
|
||||||
(!sessionEntry || sessionEntry.sessionId !== opts.sessionId)
|
(!sessionKey || sessionStore[sessionKey]?.sessionId !== opts.sessionId)
|
||||||
) {
|
) {
|
||||||
const foundKey = Object.keys(sessionStore).find(
|
const foundKey = Object.keys(sessionStore).find(
|
||||||
(key) => sessionStore[key]?.sessionId === opts.sessionId,
|
(key) => sessionStore[key]?.sessionId === opts.sessionId,
|
||||||
);
|
);
|
||||||
if (foundKey) {
|
if (foundKey) sessionKey = foundKey;
|
||||||
sessionKey = sessionKey ?? foundKey;
|
|
||||||
sessionEntry = sessionStore[foundKey];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { sessionKey, sessionStore, storePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSession(opts: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
to?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
sessionKey?: string;
|
||||||
|
agentId?: string;
|
||||||
|
}): SessionResolution {
|
||||||
|
const sessionCfg = opts.cfg.session;
|
||||||
|
const { sessionKey, sessionStore, storePath } = resolveSessionKeyForRequest({
|
||||||
|
cfg: opts.cfg,
|
||||||
|
to: opts.to,
|
||||||
|
sessionId: opts.sessionId,
|
||||||
|
sessionKey: opts.sessionKey,
|
||||||
|
agentId: opts.agentId,
|
||||||
|
});
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const sessionEntry = sessionKey ? sessionStore[sessionKey] : undefined;
|
||||||
|
|
||||||
const resetType = resolveSessionResetType({ sessionKey });
|
const resetType = resolveSessionResetType({ sessionKey });
|
||||||
const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType });
|
const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType });
|
||||||
const fresh = sessionEntry
|
const fresh = sessionEntry
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export type AgentCommandOpts = {
|
|||||||
message: string;
|
message: string;
|
||||||
/** Optional image attachments for multimodal messages. */
|
/** Optional image attachments for multimodal messages. */
|
||||||
images?: ImageContent[];
|
images?: ImageContent[];
|
||||||
|
/** Agent id override (must exist in config). */
|
||||||
|
agentId?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
@@ -20,6 +22,12 @@ export type AgentCommandOpts = {
|
|||||||
json?: boolean;
|
json?: boolean;
|
||||||
timeout?: string;
|
timeout?: string;
|
||||||
deliver?: boolean;
|
deliver?: boolean;
|
||||||
|
/** Override delivery target (separate from session routing). */
|
||||||
|
replyTo?: string;
|
||||||
|
/** Override delivery channel (separate from session routing). */
|
||||||
|
replyChannel?: string;
|
||||||
|
/** Override delivery account id (separate from session routing). */
|
||||||
|
replyAccountId?: string;
|
||||||
/** Message channel context (webchat|voicewake|whatsapp|...). */
|
/** Message channel context (webchat|voicewake|whatsapp|...). */
|
||||||
messageChannel?: string;
|
messageChannel?: string;
|
||||||
channel?: string; // delivery channel (whatsapp|telegram|...)
|
channel?: string; // delivery channel (whatsapp|telegram|...)
|
||||||
|
|||||||
@@ -35,6 +35,15 @@ export function resolveAgentMainSessionKey(params: {
|
|||||||
return buildAgentMainSessionKey({ agentId: params.agentId, mainKey });
|
return buildAgentMainSessionKey({ agentId: params.agentId, mainKey });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveExplicitAgentSessionKey(params: {
|
||||||
|
cfg?: { session?: { scope?: SessionScope; mainKey?: string } };
|
||||||
|
agentId?: string | null;
|
||||||
|
}): string | undefined {
|
||||||
|
const agentId = params.agentId?.trim();
|
||||||
|
if (!agentId) return undefined;
|
||||||
|
return resolveAgentMainSessionKey({ cfg: params.cfg, agentId });
|
||||||
|
}
|
||||||
|
|
||||||
export function canonicalizeMainSessionAlias(params: {
|
export function canonicalizeMainSessionAlias(params: {
|
||||||
cfg?: { session?: { scope?: SessionScope; mainKey?: string } };
|
cfg?: { session?: { scope?: SessionScope; mainKey?: string } };
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
|||||||
@@ -45,14 +45,18 @@ export const PollParamsSchema = Type.Object(
|
|||||||
export const AgentParamsSchema = Type.Object(
|
export const AgentParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
message: NonEmptyString,
|
message: NonEmptyString,
|
||||||
|
agentId: Type.Optional(NonEmptyString),
|
||||||
to: Type.Optional(Type.String()),
|
to: Type.Optional(Type.String()),
|
||||||
|
replyTo: Type.Optional(Type.String()),
|
||||||
sessionId: Type.Optional(Type.String()),
|
sessionId: Type.Optional(Type.String()),
|
||||||
sessionKey: Type.Optional(Type.String()),
|
sessionKey: Type.Optional(Type.String()),
|
||||||
thinking: Type.Optional(Type.String()),
|
thinking: Type.Optional(Type.String()),
|
||||||
deliver: Type.Optional(Type.Boolean()),
|
deliver: Type.Optional(Type.Boolean()),
|
||||||
attachments: Type.Optional(Type.Array(Type.Unknown())),
|
attachments: Type.Optional(Type.Array(Type.Unknown())),
|
||||||
channel: Type.Optional(Type.String()),
|
channel: Type.Optional(Type.String()),
|
||||||
|
replyChannel: Type.Optional(Type.String()),
|
||||||
accountId: Type.Optional(Type.String()),
|
accountId: Type.Optional(Type.String()),
|
||||||
|
replyAccountId: Type.Optional(Type.String()),
|
||||||
timeout: Type.Optional(Type.Integer({ minimum: 0 })),
|
timeout: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||||
lane: Type.Optional(Type.String()),
|
lane: Type.Optional(Type.String()),
|
||||||
extraSystemPrompt: Type.Optional(Type.String()),
|
extraSystemPrompt: Type.Optional(Type.String()),
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { agentCommand } from "../../commands/agent.js";
|
import { agentCommand } from "../../commands/agent.js";
|
||||||
|
import { listAgentIds } from "../../agents/agent-scope.js";
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
import {
|
import {
|
||||||
resolveAgentIdFromSessionKey,
|
resolveAgentIdFromSessionKey,
|
||||||
|
resolveExplicitAgentSessionKey,
|
||||||
resolveAgentMainSessionKey,
|
resolveAgentMainSessionKey,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
updateSessionStore,
|
updateSessionStore,
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
isGatewayMessageChannel,
|
isGatewayMessageChannel,
|
||||||
normalizeMessageChannel,
|
normalizeMessageChannel,
|
||||||
} from "../../utils/message-channel.js";
|
} from "../../utils/message-channel.js";
|
||||||
|
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||||
import { parseMessageWithAttachments } from "../chat-attachments.js";
|
import { parseMessageWithAttachments } from "../chat-attachments.js";
|
||||||
import {
|
import {
|
||||||
type AgentWaitParams,
|
type AgentWaitParams,
|
||||||
@@ -51,7 +54,9 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
}
|
}
|
||||||
const request = p as {
|
const request = p as {
|
||||||
message: string;
|
message: string;
|
||||||
|
agentId?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
|
replyTo?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
@@ -63,7 +68,9 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
content?: unknown;
|
content?: unknown;
|
||||||
}>;
|
}>;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
|
replyChannel?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
replyAccountId?: string;
|
||||||
lane?: string;
|
lane?: string;
|
||||||
extraSystemPrompt?: string;
|
extraSystemPrompt?: string;
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
@@ -71,6 +78,7 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
label?: string;
|
label?: string;
|
||||||
spawnedBy?: string;
|
spawnedBy?: string;
|
||||||
};
|
};
|
||||||
|
const cfg = loadConfig();
|
||||||
const idem = request.idempotencyKey;
|
const idem = request.idempotencyKey;
|
||||||
const cached = context.dedupe.get(`agent:${idem}`);
|
const cached = context.dedupe.get(`agent:${idem}`);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -113,9 +121,12 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const rawChannel = typeof request.channel === "string" ? request.channel.trim() : "";
|
const isKnownGatewayChannel = (value: string): boolean => isGatewayMessageChannel(value);
|
||||||
if (rawChannel) {
|
const channelHints = [request.channel, request.replyChannel]
|
||||||
const isKnownGatewayChannel = (value: string): boolean => isGatewayMessageChannel(value);
|
.filter((value): value is string => typeof value === "string")
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
for (const rawChannel of channelHints) {
|
||||||
const normalized = normalizeMessageChannel(rawChannel);
|
const normalized = normalizeMessageChannel(rawChannel);
|
||||||
if (normalized && normalized !== "last" && !isKnownGatewayChannel(normalized)) {
|
if (normalized && normalized !== "last" && !isKnownGatewayChannel(normalized)) {
|
||||||
respond(
|
respond(
|
||||||
@@ -130,10 +141,47 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedSessionKey =
|
const agentIdRaw = typeof request.agentId === "string" ? request.agentId.trim() : "";
|
||||||
|
const agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : undefined;
|
||||||
|
if (agentId) {
|
||||||
|
const knownAgents = listAgentIds(cfg);
|
||||||
|
if (!knownAgents.includes(agentId)) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`invalid agent params: unknown agent id "${request.agentId}"`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedSessionKeyRaw =
|
||||||
typeof request.sessionKey === "string" && request.sessionKey.trim()
|
typeof request.sessionKey === "string" && request.sessionKey.trim()
|
||||||
? request.sessionKey.trim()
|
? request.sessionKey.trim()
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const requestedSessionKey =
|
||||||
|
requestedSessionKeyRaw ??
|
||||||
|
resolveExplicitAgentSessionKey({
|
||||||
|
cfg,
|
||||||
|
agentId,
|
||||||
|
});
|
||||||
|
if (agentId && requestedSessionKeyRaw) {
|
||||||
|
const sessionAgentId = resolveAgentIdFromSessionKey(requestedSessionKeyRaw);
|
||||||
|
if (sessionAgentId !== agentId) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`invalid agent params: agent "${request.agentId}" does not match session key agent "${sessionAgentId}"`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
let resolvedSessionId = request.sessionId?.trim() || undefined;
|
let resolvedSessionId = request.sessionId?.trim() || undefined;
|
||||||
let sessionEntry: SessionEntry | undefined;
|
let sessionEntry: SessionEntry | undefined;
|
||||||
let bestEffortDeliver = false;
|
let bestEffortDeliver = false;
|
||||||
@@ -204,12 +252,16 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
|
|
||||||
const wantsDelivery = request.deliver === true;
|
const wantsDelivery = request.deliver === true;
|
||||||
const explicitTo =
|
const explicitTo =
|
||||||
typeof request.to === "string" && request.to.trim() ? request.to.trim() : undefined;
|
typeof request.replyTo === "string" && request.replyTo.trim()
|
||||||
|
? request.replyTo.trim()
|
||||||
|
: typeof request.to === "string" && request.to.trim()
|
||||||
|
? request.to.trim()
|
||||||
|
: undefined;
|
||||||
const deliveryPlan = resolveAgentDeliveryPlan({
|
const deliveryPlan = resolveAgentDeliveryPlan({
|
||||||
sessionEntry,
|
sessionEntry,
|
||||||
requestedChannel: request.channel,
|
requestedChannel: request.replyChannel ?? request.channel,
|
||||||
explicitTo,
|
explicitTo,
|
||||||
accountId: request.accountId,
|
accountId: request.replyAccountId ?? request.accountId,
|
||||||
wantsDelivery,
|
wantsDelivery,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -219,9 +271,9 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
let resolvedTo = deliveryPlan.resolvedTo;
|
let resolvedTo = deliveryPlan.resolvedTo;
|
||||||
|
|
||||||
if (!resolvedTo && isDeliverableMessageChannel(resolvedChannel)) {
|
if (!resolvedTo && isDeliverableMessageChannel(resolvedChannel)) {
|
||||||
const cfg = cfgForAgent ?? loadConfig();
|
const cfgResolved = cfgForAgent ?? cfg;
|
||||||
const fallback = resolveAgentOutboundTarget({
|
const fallback = resolveAgentOutboundTarget({
|
||||||
cfg,
|
cfg: cfgResolved,
|
||||||
plan: deliveryPlan,
|
plan: deliveryPlan,
|
||||||
targetMode: "implicit",
|
targetMode: "implicit",
|
||||||
validateExplicitTarget: false,
|
validateExplicitTarget: false,
|
||||||
|
|||||||
@@ -214,6 +214,83 @@ describe("gateway server agent", () => {
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("agent derives sessionKey from agentId", async () => {
|
||||||
|
registryState.registry = defaultRegistry;
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
|
testState.agentsConfig = { list: [{ id: "ops" }] };
|
||||||
|
await writeSessionStore({
|
||||||
|
agentId: "ops",
|
||||||
|
entries: {
|
||||||
|
main: {
|
||||||
|
sessionId: "sess-ops",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
|
const res = await rpcReq(ws, "agent", {
|
||||||
|
message: "hi",
|
||||||
|
agentId: "ops",
|
||||||
|
idempotencyKey: "idem-agent-id",
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
|
||||||
|
const spy = vi.mocked(agentCommand);
|
||||||
|
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||||
|
expect(call.sessionKey).toBe("agent:ops:main");
|
||||||
|
expect(call.sessionId).toBe("sess-ops");
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("agent rejects unknown reply channel", async () => {
|
||||||
|
registryState.registry = defaultRegistry;
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
|
const res = await rpcReq(ws, "agent", {
|
||||||
|
message: "hi",
|
||||||
|
replyChannel: "unknown-channel",
|
||||||
|
idempotencyKey: "idem-agent-reply-unknown",
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.error?.message).toContain("unknown channel");
|
||||||
|
|
||||||
|
const spy = vi.mocked(agentCommand);
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("agent rejects mismatched agentId and sessionKey", async () => {
|
||||||
|
registryState.registry = defaultRegistry;
|
||||||
|
testState.agentsConfig = { list: [{ id: "ops" }] };
|
||||||
|
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
|
const res = await rpcReq(ws, "agent", {
|
||||||
|
message: "hi",
|
||||||
|
agentId: "ops",
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
idempotencyKey: "idem-agent-mismatch",
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.error?.message).toContain("does not match session key agent");
|
||||||
|
|
||||||
|
const spy = vi.mocked(agentCommand);
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("agent forwards accountId to agentCommand", async () => {
|
test("agent forwards accountId to agentCommand", async () => {
|
||||||
registryState.registry = defaultRegistry;
|
registryState.registry = defaultRegistry;
|
||||||
testState.allowFrom = ["+1555"];
|
testState.allowFrom = ["+1555"];
|
||||||
|
|||||||
Reference in New Issue
Block a user