fix(agent): align tools + preserve indentation

This commit is contained in:
Peter Steinberger
2026-01-05 17:55:20 +00:00
parent 196eb86e38
commit 7c89ce93b5
12 changed files with 88 additions and 46 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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", () => {

View File

@@ -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;

View File

@@ -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,

View File

@@ -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:

View File

@@ -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,

View File

@@ -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;
} }

View File

@@ -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(),

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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,