From 7c89ce93b5bb8ae2b258d4064c9724b2d97ea2fd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 5 Jan 2026 17:55:20 +0000 Subject: [PATCH] fix(agent): align tools + preserve indentation --- CHANGELOG.md | 1 + src/agents/pi-embedded-runner.ts | 41 ++++++++++++++++++++++-- src/agents/pi-embedded-subscribe.test.ts | 4 +-- src/agents/pi-embedded-subscribe.ts | 40 +++++++++++++++-------- src/commands/configure.ts | 6 +++- src/commands/doctor.ts | 4 +-- src/commands/onboard-non-interactive.ts | 2 +- src/commands/systemd-linger.ts | 17 ++++------ src/daemon/systemd.test.ts | 3 +- src/daemon/systemd.ts | 10 ++---- src/media/parse.ts | 4 +-- src/wizard/onboarding.ts | 2 +- 12 files changed, 88 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be82743b3..eb5d3d6cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - Status: show runtime (docker/direct) and move shortcuts to `/help`. - Status: show model auth source (api-key/oauth). - 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. ### Maintenance diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 85429db53..db5659e24 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -1,7 +1,12 @@ import fs from "node:fs/promises"; 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 { buildSystemPrompt, @@ -11,6 +16,7 @@ import { SessionManager, SettingsManager, type Skill, + type ToolDefinition, } from "@mariozechner/pi-coding-agent"; import type { ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js"; import { formatToolAggregate } from "../auto-reply/tool-meta.js"; @@ -52,6 +58,35 @@ import { import { buildAgentSystemPromptAppend } from "./system-prompt.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, + ) => Promise>; + }; + 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 = { sessionId: 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) const builtInToolNames = new Set(["read", "bash", "edit", "write"]); 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({ cwd: resolvedWorkspace, diff --git a/src/agents/pi-embedded-subscribe.test.ts b/src/agents/pi-embedded-subscribe.test.ts index 3c37d8025..8c7751c51 100644 --- a/src/agents/pi-embedded-subscribe.test.ts +++ b/src/agents/pi-embedded-subscribe.test.ts @@ -780,9 +780,7 @@ describe("subscribeEmbeddedPiSession", () => { handler?.({ type: "message_end", message: assistantMessage }); expect(onBlockReply).toHaveBeenCalledTimes(3); - expect(onBlockReply.mock.calls[1][0].text).toBe( - "~~~sh\nline1\nline2\n~~~", - ); + expect(onBlockReply.mock.calls[1][0].text).toBe("~~~sh\nline1\nline2\n~~~"); }); it("keeps indented fenced blocks intact", () => { diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index 5f9839d4d..d5204c92a 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -284,30 +284,42 @@ export function subscribeEmbeddedPiSession(params: { const isSafeBreak = (spans: FenceSpan[], index: number): boolean => !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 }; - 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 }; const fenceSpans = parseFenceSpans(buffer); const preference = blockChunking.breakPreference ?? "paragraph"; if (preference === "paragraph") { - let paragraphIdx = buffer.lastIndexOf("\n\n"); - while (paragraphIdx >= minChars) { - if (isSafeBreak(fenceSpans, paragraphIdx)) { + let paragraphIdx = buffer.indexOf("\n\n"); + while (paragraphIdx !== -1) { + if (paragraphIdx >= minChars && isSafeBreak(fenceSpans, paragraphIdx)) { return { index: paragraphIdx }; } - paragraphIdx = buffer.lastIndexOf("\n\n", paragraphIdx - 1); + paragraphIdx = buffer.indexOf("\n\n", paragraphIdx + 2); } } if (preference === "paragraph" || preference === "newline") { - let newlineIdx = buffer.lastIndexOf("\n"); - while (newlineIdx >= minChars) { - if (isSafeBreak(fenceSpans, newlineIdx)) { + let newlineIdx = buffer.indexOf("\n"); + while (newlineIdx !== -1) { + if (newlineIdx >= minChars && isSafeBreak(fenceSpans, 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 = force && blockBuffer.length <= maxChars - ? pickSoftBreakIndex(blockBuffer) + ? pickSoftBreakIndex(blockBuffer, 1) : pickBreakIndex(blockBuffer); if (breakResult.index <= 0) { if (force) { @@ -434,7 +446,9 @@ export function subscribeEmbeddedPiSession(params: { const breakIdx = breakResult.index; let rawChunk = blockBuffer.slice(0, breakIdx); if (rawChunk.trim().length === 0) { - blockBuffer = blockBuffer.slice(breakIdx).trimStart(); + blockBuffer = stripLeadingNewlines( + blockBuffer.slice(breakIdx), + ).trimStart(); continue; } let nextBuffer = blockBuffer.slice(breakIdx); @@ -457,7 +471,7 @@ export function subscribeEmbeddedPiSession(params: { breakIdx < blockBuffer.length && /\s/.test(blockBuffer[breakIdx]) ? breakIdx + 1 : breakIdx; - blockBuffer = blockBuffer.slice(nextStart).trimStart(); + blockBuffer = stripLeadingNewlines(blockBuffer.slice(nextStart)); } if (blockBuffer.length < minChars && !force) return; if (blockBuffer.length < maxChars && !force) return; diff --git a/src/commands/configure.ts b/src/commands/configure.ts index bccbf190b..dda13dc83 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -424,7 +424,11 @@ async function maybeInstallDaemon(params: { if (shouldCheckLinger) { await ensureSystemdUserLingerInteractive({ runtime: params.runtime, - prompter: { confirm, note }, + prompter: { + confirm: async (p) => + guardCancel(await confirm(p), params.runtime) === true, + note, + }, reason: "Linux installs use a systemd user service. Without lingering, systemd stops the user session on logout/idle and kills the Gateway.", requireConfirm: true, diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index cad0aacb8..b3e886b30 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -31,13 +31,13 @@ import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, sleep } from "../utils.js"; import { healthCommand } from "./health.js"; -import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js"; import { applyWizardMetadata, DEFAULT_WORKSPACE, guardCancel, printWizardHeader, } from "./onboard-helpers.js"; +import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js"; function resolveMode(cfg: ClawdbotConfig): "local" | "remote" { return cfg.gateway?.mode === "remote" ? "remote" : "local"; @@ -612,7 +612,7 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) { await ensureSystemdUserLingerInteractive({ runtime, prompter: { - confirm: (params) => guardCancel(confirm(params), runtime), + confirm: async (p) => guardCancel(await confirm(p), runtime) === true, note, }, reason: diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index 639472398..016c6fd3b 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -13,7 +13,6 @@ import { resolveGatewayService } from "../daemon/service.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, sleep } from "../utils.js"; -import { ensureSystemdUserLingerNonInteractive } from "./systemd-linger.js"; import { healthCommand } from "./health.js"; import { applyMinimaxConfig, setAnthropicApiKey } from "./onboard-auth.js"; import { @@ -27,6 +26,7 @@ import type { OnboardMode, OnboardOptions, } from "./onboard-types.js"; +import { ensureSystemdUserLingerNonInteractive } from "./systemd-linger.js"; export async function runNonInteractiveOnboarding( opts: OnboardOptions, diff --git a/src/commands/systemd-linger.ts b/src/commands/systemd-linger.ts index 172a0cb80..c1043e73e 100644 --- a/src/commands/systemd-linger.ts +++ b/src/commands/systemd-linger.ts @@ -7,9 +7,10 @@ import { import type { RuntimeEnv } from "../runtime.js"; export type LingerPrompter = { - confirm?: (params: { message: string; initialValue?: boolean }) => Promise< - boolean - >; + confirm?: (params: { + message: string; + initialValue?: boolean; + }) => Promise; note: (message: string, title?: string) => Promise | void; }; @@ -43,10 +44,7 @@ export async function ensureSystemdUserLingerInteractive(params: { const actionNote = params.requireConfirm ? "We can enable lingering now (needs sudo; writes /var/lib/systemd/linger)." : "Enabling lingering now (needs sudo; writes /var/lib/systemd/linger)."; - await prompter.note( - `${reason}\n${actionNote}`, - title, - ); + await prompter.note(`${reason}\n${actionNote}`, title); if (params.requireConfirm && prompter.confirm) { const ok = await prompter.confirm({ @@ -68,10 +66,7 @@ export async function ensureSystemdUserLingerInteractive(params: { sudoMode: "prompt", }); if (result.ok) { - await prompter.note( - `Enabled systemd lingering for ${status.user}.`, - title, - ); + await prompter.note(`Enabled systemd lingering for ${status.user}.`, title); return; } diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index 25a8f28a4..754a8f585 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { readSystemdUserLingerStatus } from "./systemd.js"; import { runExec } from "../process/exec.js"; +import { readSystemdUserLingerStatus } from "./systemd.js"; vi.mock("../process/exec.js", () => ({ runExec: vi.fn(), diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 647a133fd..0906b2294 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -3,12 +3,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; - +import { runCommandWithTimeout, runExec } from "../process/exec.js"; import { GATEWAY_SYSTEMD_SERVICE_NAME, LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES, } from "./constants.js"; -import { runCommandWithTimeout, runExec } from "../process/exec.js"; const execFileAsync = promisify(execFile); @@ -89,12 +88,7 @@ export async function enableSystemdUserLinger(params: { needsSudo && params.sudoMode !== undefined ? ["sudo", ...(params.sudoMode === "non-interactive" ? ["-n"] : [])] : []; - const argv = [ - ...sudoArgs, - "loginctl", - "enable-linger", - user, - ]; + const argv = [...sudoArgs, "loginctl", "enable-linger", user]; try { const result = await runCommandWithTimeout(argv, { timeoutMs: 30_000 }); return { diff --git a/src/media/parse.ts b/src/media/parse.ts index d6d76334b..4f7c21e5c 100644 --- a/src/media/parse.ts +++ b/src/media/parse.ts @@ -27,8 +27,8 @@ export function splitMediaFromOutput(raw: string): { mediaUrls?: string[]; mediaUrl?: string; // legacy first item for backward compatibility } { - const trimmedRaw = raw.trim(); - if (!trimmedRaw) return { text: "" }; + const trimmedRaw = raw.trimEnd(); + if (!trimmedRaw.trim()) return { text: "" }; const media: string[] = []; let foundMediaToken = false; diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 4cc081142..576a5a466 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -34,7 +34,6 @@ import { import { setupProviders } from "../commands/onboard-providers.js"; import { promptRemoteGatewayConfig } from "../commands/onboard-remote.js"; import { setupSkills } from "../commands/onboard-skills.js"; -import { ensureSystemdUserLingerInteractive } from "../commands/systemd-linger.js"; import type { AuthChoice, GatewayAuthChoice, @@ -42,6 +41,7 @@ import type { OnboardOptions, ResetScope, } from "../commands/onboard-types.js"; +import { ensureSystemdUserLingerInteractive } from "../commands/systemd-linger.js"; import type { ClawdbotConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT,