fix(agent): align tools + preserve indentation
This commit is contained in:
@@ -24,6 +24,7 @@
|
|||||||
- Status: show runtime (docker/direct) and move shortcuts to `/help`.
|
- Status: show runtime (docker/direct) and move shortcuts to `/help`.
|
||||||
- Status: show model auth source (api-key/oauth).
|
- Status: show model auth source (api-key/oauth).
|
||||||
- Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split.
|
- Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split.
|
||||||
|
- Block streaming: preserve leading indentation in block replies (lists, indented fences).
|
||||||
- Docs: document systemd lingering and logged-in session requirements on macOS/Windows.
|
- Docs: document systemd lingering and logged-in session requirements on macOS/Windows.
|
||||||
|
|
||||||
### Maintenance
|
### Maintenance
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
|
||||||
import type { AgentMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
|
import type {
|
||||||
|
AgentMessage,
|
||||||
|
AgentToolResult,
|
||||||
|
AgentToolUpdateCallback,
|
||||||
|
ThinkingLevel,
|
||||||
|
} from "@mariozechner/pi-agent-core";
|
||||||
import type { Api, AssistantMessage, Model } from "@mariozechner/pi-ai";
|
import type { Api, AssistantMessage, Model } from "@mariozechner/pi-ai";
|
||||||
import {
|
import {
|
||||||
buildSystemPrompt,
|
buildSystemPrompt,
|
||||||
@@ -11,6 +16,7 @@ import {
|
|||||||
SessionManager,
|
SessionManager,
|
||||||
SettingsManager,
|
SettingsManager,
|
||||||
type Skill,
|
type Skill,
|
||||||
|
type ToolDefinition,
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} from "@mariozechner/pi-coding-agent";
|
||||||
import type { ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js";
|
import type { ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js";
|
||||||
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
|
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
|
||||||
@@ -52,6 +58,35 @@ import {
|
|||||||
import { buildAgentSystemPromptAppend } from "./system-prompt.js";
|
import { buildAgentSystemPromptAppend } from "./system-prompt.js";
|
||||||
import { loadWorkspaceBootstrapFiles } from "./workspace.js";
|
import { loadWorkspaceBootstrapFiles } from "./workspace.js";
|
||||||
|
|
||||||
|
function toToolDefinitions(tools: { execute: unknown }[]): ToolDefinition[] {
|
||||||
|
return tools.map((tool) => {
|
||||||
|
const record = tool as {
|
||||||
|
name?: unknown;
|
||||||
|
label?: unknown;
|
||||||
|
description?: unknown;
|
||||||
|
parameters?: unknown;
|
||||||
|
execute: (
|
||||||
|
toolCallId: string,
|
||||||
|
params: unknown,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
onUpdate?: AgentToolUpdateCallback<unknown>,
|
||||||
|
) => Promise<AgentToolResult<unknown>>;
|
||||||
|
};
|
||||||
|
const name = typeof record.name === "string" ? record.name : "tool";
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
label: typeof record.label === "string" ? record.label : name,
|
||||||
|
description:
|
||||||
|
typeof record.description === "string" ? record.description : "",
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: TypeBox schema from pi-agent-core uses a different module instance.
|
||||||
|
parameters: record.parameters as any,
|
||||||
|
execute: async (toolCallId, params, onUpdate, _ctx, signal) => {
|
||||||
|
return await record.execute(toolCallId, params, signal, onUpdate);
|
||||||
|
},
|
||||||
|
} satisfies ToolDefinition;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export type EmbeddedPiAgentMeta = {
|
export type EmbeddedPiAgentMeta = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -412,7 +447,9 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
// Split tools into built-in (recognized by pi-coding-agent SDK) and custom (clawdbot-specific)
|
// Split tools into built-in (recognized by pi-coding-agent SDK) and custom (clawdbot-specific)
|
||||||
const builtInToolNames = new Set(["read", "bash", "edit", "write"]);
|
const builtInToolNames = new Set(["read", "bash", "edit", "write"]);
|
||||||
const builtInTools = tools.filter((t) => builtInToolNames.has(t.name));
|
const builtInTools = tools.filter((t) => builtInToolNames.has(t.name));
|
||||||
const customTools = tools.filter((t) => !builtInToolNames.has(t.name));
|
const customTools = toToolDefinitions(
|
||||||
|
tools.filter((t) => !builtInToolNames.has(t.name)),
|
||||||
|
);
|
||||||
|
|
||||||
const { session } = await createAgentSession({
|
const { session } = await createAgentSession({
|
||||||
cwd: resolvedWorkspace,
|
cwd: resolvedWorkspace,
|
||||||
|
|||||||
@@ -780,9 +780,7 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
handler?.({ type: "message_end", message: assistantMessage });
|
handler?.({ type: "message_end", message: assistantMessage });
|
||||||
|
|
||||||
expect(onBlockReply).toHaveBeenCalledTimes(3);
|
expect(onBlockReply).toHaveBeenCalledTimes(3);
|
||||||
expect(onBlockReply.mock.calls[1][0].text).toBe(
|
expect(onBlockReply.mock.calls[1][0].text).toBe("~~~sh\nline1\nline2\n~~~");
|
||||||
"~~~sh\nline1\nline2\n~~~",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps indented fenced blocks intact", () => {
|
it("keeps indented fenced blocks intact", () => {
|
||||||
|
|||||||
@@ -284,30 +284,42 @@ export function subscribeEmbeddedPiSession(params: {
|
|||||||
const isSafeBreak = (spans: FenceSpan[], index: number): boolean =>
|
const isSafeBreak = (spans: FenceSpan[], index: number): boolean =>
|
||||||
!findFenceSpanAt(spans, index);
|
!findFenceSpanAt(spans, index);
|
||||||
|
|
||||||
const pickSoftBreakIndex = (buffer: string): BreakResult => {
|
const stripLeadingNewlines = (value: string): string => {
|
||||||
|
let i = 0;
|
||||||
|
while (i < value.length && value[i] === "\n") i++;
|
||||||
|
return i > 0 ? value.slice(i) : value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickSoftBreakIndex = (
|
||||||
|
buffer: string,
|
||||||
|
minCharsOverride?: number,
|
||||||
|
): BreakResult => {
|
||||||
if (!blockChunking) return { index: -1 };
|
if (!blockChunking) return { index: -1 };
|
||||||
const minChars = Math.max(1, Math.floor(blockChunking.minChars));
|
const minChars = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(minCharsOverride ?? blockChunking.minChars),
|
||||||
|
);
|
||||||
if (buffer.length < minChars) return { index: -1 };
|
if (buffer.length < minChars) return { index: -1 };
|
||||||
const fenceSpans = parseFenceSpans(buffer);
|
const fenceSpans = parseFenceSpans(buffer);
|
||||||
const preference = blockChunking.breakPreference ?? "paragraph";
|
const preference = blockChunking.breakPreference ?? "paragraph";
|
||||||
|
|
||||||
if (preference === "paragraph") {
|
if (preference === "paragraph") {
|
||||||
let paragraphIdx = buffer.lastIndexOf("\n\n");
|
let paragraphIdx = buffer.indexOf("\n\n");
|
||||||
while (paragraphIdx >= minChars) {
|
while (paragraphIdx !== -1) {
|
||||||
if (isSafeBreak(fenceSpans, paragraphIdx)) {
|
if (paragraphIdx >= minChars && isSafeBreak(fenceSpans, paragraphIdx)) {
|
||||||
return { index: paragraphIdx };
|
return { index: paragraphIdx };
|
||||||
}
|
}
|
||||||
paragraphIdx = buffer.lastIndexOf("\n\n", paragraphIdx - 1);
|
paragraphIdx = buffer.indexOf("\n\n", paragraphIdx + 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preference === "paragraph" || preference === "newline") {
|
if (preference === "paragraph" || preference === "newline") {
|
||||||
let newlineIdx = buffer.lastIndexOf("\n");
|
let newlineIdx = buffer.indexOf("\n");
|
||||||
while (newlineIdx >= minChars) {
|
while (newlineIdx !== -1) {
|
||||||
if (isSafeBreak(fenceSpans, newlineIdx)) {
|
if (newlineIdx >= minChars && isSafeBreak(fenceSpans, newlineIdx)) {
|
||||||
return { index: newlineIdx };
|
return { index: newlineIdx };
|
||||||
}
|
}
|
||||||
newlineIdx = buffer.lastIndexOf("\n", newlineIdx - 1);
|
newlineIdx = buffer.indexOf("\n", newlineIdx + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,7 +434,7 @@ export function subscribeEmbeddedPiSession(params: {
|
|||||||
) {
|
) {
|
||||||
const breakResult =
|
const breakResult =
|
||||||
force && blockBuffer.length <= maxChars
|
force && blockBuffer.length <= maxChars
|
||||||
? pickSoftBreakIndex(blockBuffer)
|
? pickSoftBreakIndex(blockBuffer, 1)
|
||||||
: pickBreakIndex(blockBuffer);
|
: pickBreakIndex(blockBuffer);
|
||||||
if (breakResult.index <= 0) {
|
if (breakResult.index <= 0) {
|
||||||
if (force) {
|
if (force) {
|
||||||
@@ -434,7 +446,9 @@ export function subscribeEmbeddedPiSession(params: {
|
|||||||
const breakIdx = breakResult.index;
|
const breakIdx = breakResult.index;
|
||||||
let rawChunk = blockBuffer.slice(0, breakIdx);
|
let rawChunk = blockBuffer.slice(0, breakIdx);
|
||||||
if (rawChunk.trim().length === 0) {
|
if (rawChunk.trim().length === 0) {
|
||||||
blockBuffer = blockBuffer.slice(breakIdx).trimStart();
|
blockBuffer = stripLeadingNewlines(
|
||||||
|
blockBuffer.slice(breakIdx),
|
||||||
|
).trimStart();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let nextBuffer = blockBuffer.slice(breakIdx);
|
let nextBuffer = blockBuffer.slice(breakIdx);
|
||||||
@@ -457,7 +471,7 @@ export function subscribeEmbeddedPiSession(params: {
|
|||||||
breakIdx < blockBuffer.length && /\s/.test(blockBuffer[breakIdx])
|
breakIdx < blockBuffer.length && /\s/.test(blockBuffer[breakIdx])
|
||||||
? breakIdx + 1
|
? breakIdx + 1
|
||||||
: breakIdx;
|
: breakIdx;
|
||||||
blockBuffer = blockBuffer.slice(nextStart).trimStart();
|
blockBuffer = stripLeadingNewlines(blockBuffer.slice(nextStart));
|
||||||
}
|
}
|
||||||
if (blockBuffer.length < minChars && !force) return;
|
if (blockBuffer.length < minChars && !force) return;
|
||||||
if (blockBuffer.length < maxChars && !force) return;
|
if (blockBuffer.length < maxChars && !force) return;
|
||||||
|
|||||||
@@ -424,7 +424,11 @@ async function maybeInstallDaemon(params: {
|
|||||||
if (shouldCheckLinger) {
|
if (shouldCheckLinger) {
|
||||||
await ensureSystemdUserLingerInteractive({
|
await ensureSystemdUserLingerInteractive({
|
||||||
runtime: params.runtime,
|
runtime: params.runtime,
|
||||||
prompter: { confirm, note },
|
prompter: {
|
||||||
|
confirm: async (p) =>
|
||||||
|
guardCancel(await confirm(p), params.runtime) === true,
|
||||||
|
note,
|
||||||
|
},
|
||||||
reason:
|
reason:
|
||||||
"Linux installs use a systemd user service. Without lingering, systemd stops the user session on logout/idle and kills the Gateway.",
|
"Linux installs use a systemd user service. Without lingering, systemd stops the user session on logout/idle and kills the Gateway.",
|
||||||
requireConfirm: true,
|
requireConfirm: true,
|
||||||
|
|||||||
@@ -31,13 +31,13 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { resolveUserPath, sleep } from "../utils.js";
|
import { resolveUserPath, sleep } from "../utils.js";
|
||||||
import { healthCommand } from "./health.js";
|
import { healthCommand } from "./health.js";
|
||||||
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
|
|
||||||
import {
|
import {
|
||||||
applyWizardMetadata,
|
applyWizardMetadata,
|
||||||
DEFAULT_WORKSPACE,
|
DEFAULT_WORKSPACE,
|
||||||
guardCancel,
|
guardCancel,
|
||||||
printWizardHeader,
|
printWizardHeader,
|
||||||
} from "./onboard-helpers.js";
|
} from "./onboard-helpers.js";
|
||||||
|
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
|
||||||
|
|
||||||
function resolveMode(cfg: ClawdbotConfig): "local" | "remote" {
|
function resolveMode(cfg: ClawdbotConfig): "local" | "remote" {
|
||||||
return cfg.gateway?.mode === "remote" ? "remote" : "local";
|
return cfg.gateway?.mode === "remote" ? "remote" : "local";
|
||||||
@@ -612,7 +612,7 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) {
|
|||||||
await ensureSystemdUserLingerInteractive({
|
await ensureSystemdUserLingerInteractive({
|
||||||
runtime,
|
runtime,
|
||||||
prompter: {
|
prompter: {
|
||||||
confirm: (params) => guardCancel(confirm(params), runtime),
|
confirm: async (p) => guardCancel(await confirm(p), runtime) === true,
|
||||||
note,
|
note,
|
||||||
},
|
},
|
||||||
reason:
|
reason:
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { resolveGatewayService } from "../daemon/service.js";
|
|||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { resolveUserPath, sleep } from "../utils.js";
|
import { resolveUserPath, sleep } from "../utils.js";
|
||||||
import { ensureSystemdUserLingerNonInteractive } from "./systemd-linger.js";
|
|
||||||
import { healthCommand } from "./health.js";
|
import { healthCommand } from "./health.js";
|
||||||
import { applyMinimaxConfig, setAnthropicApiKey } from "./onboard-auth.js";
|
import { applyMinimaxConfig, setAnthropicApiKey } from "./onboard-auth.js";
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +26,7 @@ import type {
|
|||||||
OnboardMode,
|
OnboardMode,
|
||||||
OnboardOptions,
|
OnboardOptions,
|
||||||
} from "./onboard-types.js";
|
} from "./onboard-types.js";
|
||||||
|
import { ensureSystemdUserLingerNonInteractive } from "./systemd-linger.js";
|
||||||
|
|
||||||
export async function runNonInteractiveOnboarding(
|
export async function runNonInteractiveOnboarding(
|
||||||
opts: OnboardOptions,
|
opts: OnboardOptions,
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import {
|
|||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
|
||||||
export type LingerPrompter = {
|
export type LingerPrompter = {
|
||||||
confirm?: (params: { message: string; initialValue?: boolean }) => Promise<
|
confirm?: (params: {
|
||||||
boolean
|
message: string;
|
||||||
>;
|
initialValue?: boolean;
|
||||||
|
}) => Promise<boolean>;
|
||||||
note: (message: string, title?: string) => Promise<void> | void;
|
note: (message: string, title?: string) => Promise<void> | void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,10 +44,7 @@ export async function ensureSystemdUserLingerInteractive(params: {
|
|||||||
const actionNote = params.requireConfirm
|
const actionNote = params.requireConfirm
|
||||||
? "We can enable lingering now (needs sudo; writes /var/lib/systemd/linger)."
|
? "We can enable lingering now (needs sudo; writes /var/lib/systemd/linger)."
|
||||||
: "Enabling lingering now (needs sudo; writes /var/lib/systemd/linger).";
|
: "Enabling lingering now (needs sudo; writes /var/lib/systemd/linger).";
|
||||||
await prompter.note(
|
await prompter.note(`${reason}\n${actionNote}`, title);
|
||||||
`${reason}\n${actionNote}`,
|
|
||||||
title,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (params.requireConfirm && prompter.confirm) {
|
if (params.requireConfirm && prompter.confirm) {
|
||||||
const ok = await prompter.confirm({
|
const ok = await prompter.confirm({
|
||||||
@@ -68,10 +66,7 @@ export async function ensureSystemdUserLingerInteractive(params: {
|
|||||||
sudoMode: "prompt",
|
sudoMode: "prompt",
|
||||||
});
|
});
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
await prompter.note(
|
await prompter.note(`Enabled systemd lingering for ${status.user}.`, title);
|
||||||
`Enabled systemd lingering for ${status.user}.`,
|
|
||||||
title,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { readSystemdUserLingerStatus } from "./systemd.js";
|
|
||||||
import { runExec } from "../process/exec.js";
|
import { runExec } from "../process/exec.js";
|
||||||
|
import { readSystemdUserLingerStatus } from "./systemd.js";
|
||||||
|
|
||||||
vi.mock("../process/exec.js", () => ({
|
vi.mock("../process/exec.js", () => ({
|
||||||
runExec: vi.fn(),
|
runExec: vi.fn(),
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
import { runCommandWithTimeout, runExec } from "../process/exec.js";
|
||||||
import {
|
import {
|
||||||
GATEWAY_SYSTEMD_SERVICE_NAME,
|
GATEWAY_SYSTEMD_SERVICE_NAME,
|
||||||
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
|
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
|
||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
import { runCommandWithTimeout, runExec } from "../process/exec.js";
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@@ -89,12 +88,7 @@ export async function enableSystemdUserLinger(params: {
|
|||||||
needsSudo && params.sudoMode !== undefined
|
needsSudo && params.sudoMode !== undefined
|
||||||
? ["sudo", ...(params.sudoMode === "non-interactive" ? ["-n"] : [])]
|
? ["sudo", ...(params.sudoMode === "non-interactive" ? ["-n"] : [])]
|
||||||
: [];
|
: [];
|
||||||
const argv = [
|
const argv = [...sudoArgs, "loginctl", "enable-linger", user];
|
||||||
...sudoArgs,
|
|
||||||
"loginctl",
|
|
||||||
"enable-linger",
|
|
||||||
user,
|
|
||||||
];
|
|
||||||
try {
|
try {
|
||||||
const result = await runCommandWithTimeout(argv, { timeoutMs: 30_000 });
|
const result = await runCommandWithTimeout(argv, { timeoutMs: 30_000 });
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ export function splitMediaFromOutput(raw: string): {
|
|||||||
mediaUrls?: string[];
|
mediaUrls?: string[];
|
||||||
mediaUrl?: string; // legacy first item for backward compatibility
|
mediaUrl?: string; // legacy first item for backward compatibility
|
||||||
} {
|
} {
|
||||||
const trimmedRaw = raw.trim();
|
const trimmedRaw = raw.trimEnd();
|
||||||
if (!trimmedRaw) return { text: "" };
|
if (!trimmedRaw.trim()) return { text: "" };
|
||||||
|
|
||||||
const media: string[] = [];
|
const media: string[] = [];
|
||||||
let foundMediaToken = false;
|
let foundMediaToken = false;
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
import { setupProviders } from "../commands/onboard-providers.js";
|
import { setupProviders } from "../commands/onboard-providers.js";
|
||||||
import { promptRemoteGatewayConfig } from "../commands/onboard-remote.js";
|
import { promptRemoteGatewayConfig } from "../commands/onboard-remote.js";
|
||||||
import { setupSkills } from "../commands/onboard-skills.js";
|
import { setupSkills } from "../commands/onboard-skills.js";
|
||||||
import { ensureSystemdUserLingerInteractive } from "../commands/systemd-linger.js";
|
|
||||||
import type {
|
import type {
|
||||||
AuthChoice,
|
AuthChoice,
|
||||||
GatewayAuthChoice,
|
GatewayAuthChoice,
|
||||||
@@ -42,6 +41,7 @@ import type {
|
|||||||
OnboardOptions,
|
OnboardOptions,
|
||||||
ResetScope,
|
ResetScope,
|
||||||
} from "../commands/onboard-types.js";
|
} from "../commands/onboard-types.js";
|
||||||
|
import { ensureSystemdUserLingerInteractive } from "../commands/systemd-linger.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
CONFIG_PATH_CLAWDBOT,
|
CONFIG_PATH_CLAWDBOT,
|
||||||
|
|||||||
Reference in New Issue
Block a user