refactor(core): extract shared dedup helpers

This commit is contained in:
Peter Steinberger
2026-03-07 10:40:49 +00:00
parent 14c61bb33f
commit 3c71e2bd48
114 changed files with 3400 additions and 2040 deletions

View File

@@ -1,4 +1,3 @@
import crypto from "node:crypto";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import {
addAllowlistEntry,
@@ -20,11 +19,12 @@ import {
registerExecApprovalRequestForHostOrThrow,
} from "./bash-tools.exec-approval-request.js";
import {
createDefaultExecApprovalRequestContext,
resolveBaseExecApprovalDecision,
resolveApprovalDecisionOrUndefined,
resolveExecHostApprovalContext,
} from "./bash-tools.exec-host-shared.js";
import {
DEFAULT_APPROVAL_TIMEOUT_MS,
DEFAULT_NOTIFY_TAIL_CHARS,
createApprovalSlug,
emitExecSystemEvent,
@@ -138,16 +138,24 @@ export async function processGatewayAllowlist(
}
if (requiresAsk) {
const approvalId = crypto.randomUUID();
const approvalSlug = createApprovalSlug(approvalId);
const contextKey = `exec:${approvalId}`;
const {
approvalId,
approvalSlug,
contextKey,
noticeSeconds,
warningText,
expiresAtMs: defaultExpiresAtMs,
preResolvedDecision: defaultPreResolvedDecision,
} = createDefaultExecApprovalRequestContext({
warnings: params.warnings,
approvalRunningNoticeMs: params.approvalRunningNoticeMs,
createApprovalSlug,
});
const resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath;
const noticeSeconds = Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000));
const effectiveTimeout =
typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec;
const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : "";
let expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
let preResolvedDecision: string | null | undefined;
let expiresAtMs = defaultExpiresAtMs;
let preResolvedDecision = defaultPreResolvedDecision;
// Register first so the returned approval ID is actionable immediately.
const registration = await registerExecApprovalRequestForHostOrThrow({
@@ -184,24 +192,19 @@ export async function processGatewayAllowlist(
return;
}
let approvedByAsk = false;
let deniedReason: string | null = null;
const baseDecision = resolveBaseExecApprovalDecision({
decision,
askFallback,
obfuscationDetected: obfuscation.detected,
});
let approvedByAsk = baseDecision.approvedByAsk;
let deniedReason = baseDecision.deniedReason;
if (decision === "deny") {
deniedReason = "user-denied";
} else if (!decision) {
if (obfuscation.detected) {
deniedReason = "approval-timeout (obfuscation-detected)";
} else if (askFallback === "full") {
approvedByAsk = true;
} else if (askFallback === "allowlist") {
if (!analysisOk || !allowlistSatisfied) {
deniedReason = "approval-timeout (allowlist-miss)";
} else {
approvedByAsk = true;
}
if (baseDecision.timedOut && askFallback === "allowlist") {
if (!analysisOk || !allowlistSatisfied) {
deniedReason = "approval-timeout (allowlist-miss)";
} else {
deniedReason = "approval-timeout";
approvedByAsk = true;
}
} else if (decision === "allow-once") {
approvedByAsk = true;

View File

@@ -18,14 +18,12 @@ import {
registerExecApprovalRequestForHostOrThrow,
} from "./bash-tools.exec-approval-request.js";
import {
createDefaultExecApprovalRequestContext,
resolveBaseExecApprovalDecision,
resolveApprovalDecisionOrUndefined,
resolveExecHostApprovalContext,
} from "./bash-tools.exec-host-shared.js";
import {
DEFAULT_APPROVAL_TIMEOUT_MS,
createApprovalSlug,
emitExecSystemEvent,
} from "./bash-tools.exec-runtime.js";
import { createApprovalSlug, emitExecSystemEvent } from "./bash-tools.exec-runtime.js";
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
import { callGatewayTool } from "./tools/gateway.js";
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
@@ -209,13 +207,21 @@ export async function executeNodeHostCommand(
}) satisfies Record<string, unknown>;
if (requiresAsk) {
const approvalId = crypto.randomUUID();
const approvalSlug = createApprovalSlug(approvalId);
const contextKey = `exec:${approvalId}`;
const noticeSeconds = Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000));
const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : "";
let expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
let preResolvedDecision: string | null | undefined;
const {
approvalId,
approvalSlug,
contextKey,
noticeSeconds,
warningText,
expiresAtMs: defaultExpiresAtMs,
preResolvedDecision: defaultPreResolvedDecision,
} = createDefaultExecApprovalRequestContext({
warnings: params.warnings,
approvalRunningNoticeMs: params.approvalRunningNoticeMs,
createApprovalSlug,
});
let expiresAtMs = defaultExpiresAtMs;
let preResolvedDecision = defaultPreResolvedDecision;
// Register first so the returned approval ID is actionable immediately.
const registration = await registerExecApprovalRequestForHostOrThrow({
@@ -252,23 +258,17 @@ export async function executeNodeHostCommand(
return;
}
let approvedByAsk = false;
const baseDecision = resolveBaseExecApprovalDecision({
decision,
askFallback,
obfuscationDetected: obfuscation.detected,
});
let approvedByAsk = baseDecision.approvedByAsk;
let approvalDecision: "allow-once" | "allow-always" | null = null;
let deniedReason: string | null = null;
let deniedReason = baseDecision.deniedReason;
if (decision === "deny") {
deniedReason = "user-denied";
} else if (!decision) {
if (obfuscation.detected) {
deniedReason = "approval-timeout (obfuscation-detected)";
} else if (askFallback === "full") {
approvedByAsk = true;
approvalDecision = "allow-once";
} else if (askFallback === "allowlist") {
// Defer allowlist enforcement to the node host.
} else {
deniedReason = "approval-timeout";
}
if (baseDecision.timedOut && askFallback === "full" && approvedByAsk) {
approvalDecision = "allow-once";
} else if (decision === "allow-once") {
approvedByAsk = true;
approvalDecision = "allow-once";

View File

@@ -1,3 +1,4 @@
import crypto from "node:crypto";
import {
maxAsk,
minSecurity,
@@ -6,6 +7,7 @@ import {
type ExecSecurity,
} from "../infra/exec-approvals.js";
import { resolveRegisteredExecApprovalDecision } from "./bash-tools.exec-approval-request.js";
import { DEFAULT_APPROVAL_TIMEOUT_MS } from "./bash-tools.exec-runtime.js";
type ResolvedExecApprovals = ReturnType<typeof resolveExecApprovals>;
@@ -16,6 +18,110 @@ export type ExecHostApprovalContext = {
askFallback: ResolvedExecApprovals["agent"]["askFallback"];
};
export type ExecApprovalPendingState = {
warningText: string;
expiresAtMs: number;
preResolvedDecision: string | null | undefined;
};
export type ExecApprovalRequestState = ExecApprovalPendingState & {
noticeSeconds: number;
};
export function createExecApprovalPendingState(params: {
warnings: string[];
timeoutMs: number;
}): ExecApprovalPendingState {
return {
warningText: params.warnings.length ? `${params.warnings.join("\n")}\n\n` : "",
expiresAtMs: Date.now() + params.timeoutMs,
preResolvedDecision: undefined,
};
}
export function createExecApprovalRequestState(params: {
warnings: string[];
timeoutMs: number;
approvalRunningNoticeMs: number;
}): ExecApprovalRequestState {
const pendingState = createExecApprovalPendingState({
warnings: params.warnings,
timeoutMs: params.timeoutMs,
});
return {
...pendingState,
noticeSeconds: Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000)),
};
}
export function createExecApprovalRequestContext(params: {
warnings: string[];
timeoutMs: number;
approvalRunningNoticeMs: number;
createApprovalSlug: (approvalId: string) => string;
}): ExecApprovalRequestState & {
approvalId: string;
approvalSlug: string;
contextKey: string;
} {
const approvalId = crypto.randomUUID();
const pendingState = createExecApprovalRequestState({
warnings: params.warnings,
timeoutMs: params.timeoutMs,
approvalRunningNoticeMs: params.approvalRunningNoticeMs,
});
return {
...pendingState,
approvalId,
approvalSlug: params.createApprovalSlug(approvalId),
contextKey: `exec:${approvalId}`,
};
}
export function createDefaultExecApprovalRequestContext(params: {
warnings: string[];
approvalRunningNoticeMs: number;
createApprovalSlug: (approvalId: string) => string;
}) {
return createExecApprovalRequestContext({
warnings: params.warnings,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
approvalRunningNoticeMs: params.approvalRunningNoticeMs,
createApprovalSlug: params.createApprovalSlug,
});
}
export function resolveBaseExecApprovalDecision(params: {
decision: string | null;
askFallback: ResolvedExecApprovals["agent"]["askFallback"];
obfuscationDetected: boolean;
}): {
approvedByAsk: boolean;
deniedReason: string | null;
timedOut: boolean;
} {
if (params.decision === "deny") {
return { approvedByAsk: false, deniedReason: "user-denied", timedOut: false };
}
if (!params.decision) {
if (params.obfuscationDetected) {
return {
approvedByAsk: false,
deniedReason: "approval-timeout (obfuscation-detected)",
timedOut: true,
};
}
if (params.askFallback === "full") {
return { approvedByAsk: true, deniedReason: null, timedOut: true };
}
if (params.askFallback === "deny") {
return { approvedByAsk: false, deniedReason: "approval-timeout", timedOut: true };
}
return { approvedByAsk: false, deniedReason: null, timedOut: true };
}
return { approvedByAsk: false, deniedReason: null, timedOut: false };
}
export function resolveExecHostApprovalContext(params: {
agentId?: string;
security: ExecSecurity;

View File

@@ -1,7 +1,7 @@
import path from "node:path";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import type { ExecAsk, ExecHost, ExecSecurity } from "../infra/exec-approvals.js";
import { type ExecHost } from "../infra/exec-approvals.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { isDangerousHostEnvVarName } from "../infra/host-env-security.js";
import { findPathKey, mergePathPrepend } from "../infra/path-prepend.js";
@@ -11,6 +11,11 @@ import type { ProcessSession } from "./bash-process-registry.js";
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
import type { BashSandboxConfig } from "./bash-tools.shared.js";
export { applyPathPrepend, findPathKey, normalizePathPrepend } from "../infra/path-prepend.js";
export {
normalizeExecAsk,
normalizeExecHost,
normalizeExecSecurity,
} from "../infra/exec-approvals.js";
import { logWarn } from "../logger.js";
import type { ManagedRun } from "../process/supervisor/index.js";
import { getProcessSupervisor } from "../process/supervisor/index.js";
@@ -156,30 +161,6 @@ export type ExecProcessHandle = {
kill: () => void;
};
export function normalizeExecHost(value?: string | null): ExecHost | null {
const normalized = value?.trim().toLowerCase();
if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") {
return normalized;
}
return null;
}
export function normalizeExecSecurity(value?: string | null): ExecSecurity | null {
const normalized = value?.trim().toLowerCase();
if (normalized === "deny" || normalized === "allowlist" || normalized === "full") {
return normalized;
}
return null;
}
export function normalizeExecAsk(value?: string | null): ExecAsk | null {
const normalized = value?.trim().toLowerCase();
if (normalized === "off" || normalized === "on-miss" || normalized === "always") {
return normalized as ExecAsk;
}
return null;
}
export function renderExecHostLabel(host: ExecHost) {
return host === "sandbox" ? "sandbox" : host === "gateway" ? "gateway" : "node";
}

View File

@@ -8,6 +8,7 @@ import { parseBooleanValue } from "../utils/boolean.js";
import { safeJsonStringify } from "../utils/safe-json.js";
import { redactImageDataForDiagnostics } from "./payload-redaction.js";
import { getQueuedFileWriter, type QueuedFileWriter } from "./queued-file-writer.js";
import { buildAgentTraceBase } from "./trace-base.js";
export type CacheTraceStage =
| "session:loaded"
@@ -173,15 +174,7 @@ export function createCacheTrace(params: CacheTraceInit): CacheTrace | null {
const writer = params.writer ?? getWriter(cfg.filePath);
let seq = 0;
const base: Omit<CacheTraceEvent, "ts" | "seq" | "stage"> = {
runId: params.runId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
provider: params.provider,
modelId: params.modelId,
modelApi: params.modelApi,
workspaceDir: params.workspaceDir,
};
const base: Omit<CacheTraceEvent, "ts" | "seq" | "stage"> = buildAgentTraceBase(params);
const recordStage: CacheTrace["recordStage"] = (stage, payload = {}) => {
const event: CacheTraceEvent = {

View File

@@ -6,6 +6,7 @@ import {
pruneHistoryForContextShare,
splitMessagesByTokenShare,
} from "./compaction.js";
import { makeAgentAssistantMessage } from "./test-helpers/agent-message-fixtures.js";
function makeMessage(id: number, size: number): AgentMessage {
return {
@@ -24,26 +25,15 @@ function makeAssistantToolCall(
toolCallId: string,
text = "x".repeat(4000),
): AssistantMessage {
return {
role: "assistant",
return makeAgentAssistantMessage({
content: [
{ type: "text", text },
{ type: "toolCall", id: toolCallId, name: "test_tool", arguments: {} },
],
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp,
};
});
}
function makeToolResult(timestamp: number, toolCallId: string, text: string): ToolResultMessage {
@@ -229,27 +219,16 @@ describe("pruneHistoryForContextShare", () => {
// all corresponding tool_results should be removed from kept messages
const messages: AgentMessage[] = [
// Chunk 1 (will be dropped) - contains multiple tool_use blocks
{
role: "assistant",
makeAgentAssistantMessage({
content: [
{ type: "text", text: "x".repeat(4000) },
{ type: "toolCall", id: "call_a", name: "tool_a", arguments: {} },
{ type: "toolCall", id: "call_b", name: "tool_b", arguments: {} },
],
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 1,
},
}),
// Chunk 2 (will be kept) - contains orphaned tool_results
makeToolResult(2, "call_a", "result_a"),
makeToolResult(3, "call_b", "result_b"),

View File

@@ -1,6 +1,7 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, ToolResultMessage } from "@mariozechner/pi-ai";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { makeAgentAssistantMessage } from "./test-helpers/agent-message-fixtures.js";
const piCodingAgentMocks = vi.hoisted(() => ({
generateSummary: vi.fn(async () => "summary"),
@@ -21,23 +22,12 @@ vi.mock("@mariozechner/pi-coding-agent", async () => {
import { isOversizedForSummary, summarizeWithFallback } from "./compaction.js";
function makeAssistantToolCall(timestamp: number): AssistantMessage {
return {
role: "assistant",
return makeAgentAssistantMessage({
content: [{ type: "toolCall", id: "call_1", name: "browser", arguments: { action: "tabs" } }],
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp,
};
});
}
function makeToolResultWithDetails(timestamp: number): ToolResultMessage<{ raw: string }> {

View File

@@ -1,33 +1,37 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
function mockContextModuleDeps(loadConfigImpl: () => unknown) {
vi.doMock("../config/config.js", () => ({
loadConfig: loadConfigImpl,
}));
vi.doMock("./models-config.js", () => ({
ensureOpenClawModelsJson: vi.fn(async () => {}),
}));
vi.doMock("./agent-paths.js", () => ({
resolveOpenClawAgentDir: () => "/tmp/openclaw-agent",
}));
vi.doMock("./pi-model-discovery.js", () => ({
discoverAuthStorage: vi.fn(() => ({})),
discoverModels: vi.fn(() => ({
getAll: () => [],
})),
}));
}
describe("lookupContextTokens", () => {
beforeEach(() => {
vi.resetModules();
});
it("returns configured model context window on first lookup", async () => {
vi.doMock("../config/config.js", () => ({
loadConfig: () => ({
models: {
providers: {
openrouter: {
models: [{ id: "openrouter/claude-sonnet", contextWindow: 321_000 }],
},
mockContextModuleDeps(() => ({
models: {
providers: {
openrouter: {
models: [{ id: "openrouter/claude-sonnet", contextWindow: 321_000 }],
},
},
}),
}));
vi.doMock("./models-config.js", () => ({
ensureOpenClawModelsJson: vi.fn(async () => {}),
}));
vi.doMock("./agent-paths.js", () => ({
resolveOpenClawAgentDir: () => "/tmp/openclaw-agent",
}));
vi.doMock("./pi-model-discovery.js", () => ({
discoverAuthStorage: vi.fn(() => ({})),
discoverModels: vi.fn(() => ({
getAll: () => [],
})),
},
}));
const { lookupContextTokens } = await import("./context.js");
@@ -36,21 +40,7 @@ describe("lookupContextTokens", () => {
it("does not skip eager warmup when --profile is followed by -- terminator", async () => {
const loadConfigMock = vi.fn(() => ({ models: {} }));
vi.doMock("../config/config.js", () => ({
loadConfig: loadConfigMock,
}));
vi.doMock("./models-config.js", () => ({
ensureOpenClawModelsJson: vi.fn(async () => {}),
}));
vi.doMock("./agent-paths.js", () => ({
resolveOpenClawAgentDir: () => "/tmp/openclaw-agent",
}));
vi.doMock("./pi-model-discovery.js", () => ({
discoverAuthStorage: vi.fn(() => ({})),
discoverModels: vi.fn(() => ({
getAll: () => [],
})),
}));
mockContextModuleDeps(loadConfigMock);
const argvSnapshot = process.argv;
process.argv = ["node", "openclaw", "--profile", "--", "config", "validate"];
@@ -79,21 +69,7 @@ describe("lookupContextTokens", () => {
},
}));
vi.doMock("../config/config.js", () => ({
loadConfig: loadConfigMock,
}));
vi.doMock("./models-config.js", () => ({
ensureOpenClawModelsJson: vi.fn(async () => {}),
}));
vi.doMock("./agent-paths.js", () => ({
resolveOpenClawAgentDir: () => "/tmp/openclaw-agent",
}));
vi.doMock("./pi-model-discovery.js", () => ({
discoverAuthStorage: vi.fn(() => ({})),
discoverModels: vi.fn(() => ({
getAll: () => [],
})),
}));
mockContextModuleDeps(loadConfigMock);
const argvSnapshot = process.argv;
process.argv = ["node", "openclaw", "config", "validate"];

View File

@@ -19,6 +19,33 @@ function throwPathEscapesBoundary(params: {
throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`);
}
function validateRelativePathWithinBoundary(params: {
relativePath: string;
isAbsolutePath: (path: string) => boolean;
options?: RelativePathOptions;
rootResolved: string;
candidate: string;
}): string {
if (params.relativePath === "" || params.relativePath === ".") {
if (params.options?.allowRoot) {
return "";
}
throwPathEscapesBoundary({
options: params.options,
rootResolved: params.rootResolved,
candidate: params.candidate,
});
}
if (params.relativePath.startsWith("..") || params.isAbsolutePath(params.relativePath)) {
throwPathEscapesBoundary({
options: params.options,
rootResolved: params.rootResolved,
candidate: params.candidate,
});
}
return params.relativePath;
}
function toRelativePathUnderRoot(params: {
root: string;
candidate: string;
@@ -35,47 +62,44 @@ function toRelativePathUnderRoot(params: {
const rootForCompare = normalizeWindowsPathForComparison(rootResolved);
const targetForCompare = normalizeWindowsPathForComparison(resolvedCandidate);
const relative = path.win32.relative(rootForCompare, targetForCompare);
if (relative === "" || relative === ".") {
if (params.options?.allowRoot) {
return "";
}
throwPathEscapesBoundary({
options: params.options,
rootResolved,
candidate: params.candidate,
});
}
if (relative.startsWith("..") || path.win32.isAbsolute(relative)) {
throwPathEscapesBoundary({
options: params.options,
rootResolved,
candidate: params.candidate,
});
}
return relative;
return validateRelativePathWithinBoundary({
relativePath: relative,
isAbsolutePath: path.win32.isAbsolute,
options: params.options,
rootResolved,
candidate: params.candidate,
});
}
const rootResolved = path.resolve(params.root);
const resolvedCandidate = path.resolve(resolvedInput);
const relative = path.relative(rootResolved, resolvedCandidate);
if (relative === "" || relative === ".") {
if (params.options?.allowRoot) {
return "";
}
throwPathEscapesBoundary({
options: params.options,
rootResolved,
candidate: params.candidate,
});
}
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throwPathEscapesBoundary({
options: params.options,
rootResolved,
candidate: params.candidate,
});
}
return relative;
return validateRelativePathWithinBoundary({
relativePath: relative,
isAbsolutePath: path.isAbsolute,
options: params.options,
rootResolved,
candidate: params.candidate,
});
}
function toRelativeBoundaryPath(params: {
root: string;
candidate: string;
options?: Pick<RelativePathOptions, "allowRoot" | "cwd">;
boundaryLabel: string;
includeRootInError?: boolean;
}): string {
return toRelativePathUnderRoot({
root: params.root,
candidate: params.candidate,
options: {
allowRoot: params.options?.allowRoot,
cwd: params.options?.cwd,
boundaryLabel: params.boundaryLabel,
includeRootInError: params.includeRootInError,
},
});
}
export function toRelativeWorkspacePath(
@@ -83,14 +107,11 @@ export function toRelativeWorkspacePath(
candidate: string,
options?: Pick<RelativePathOptions, "allowRoot" | "cwd">,
): string {
return toRelativePathUnderRoot({
return toRelativeBoundaryPath({
root,
candidate,
options: {
allowRoot: options?.allowRoot,
cwd: options?.cwd,
boundaryLabel: "workspace root",
},
options,
boundaryLabel: "workspace root",
});
}
@@ -99,15 +120,12 @@ export function toRelativeSandboxPath(
candidate: string,
options?: Pick<RelativePathOptions, "allowRoot" | "cwd">,
): string {
return toRelativePathUnderRoot({
return toRelativeBoundaryPath({
root,
candidate,
options: {
allowRoot: options?.allowRoot,
cwd: options?.cwd,
boundaryLabel: "sandbox root",
includeRootInError: true,
},
options,
boundaryLabel: "sandbox root",
includeRootInError: true,
});
}

View File

@@ -5,15 +5,17 @@ import {
sanitizeGoogleTurnOrdering,
sanitizeSessionMessagesImages,
} from "./pi-embedded-helpers.js";
import { castAgentMessages } from "./test-helpers/agent-message-fixtures.js";
import {
castAgentMessages,
makeAgentAssistantMessage,
} from "./test-helpers/agent-message-fixtures.js";
let testTimestamp = 1;
const nextTimestamp = () => testTimestamp++;
function makeToolCallResultPairInput(): Array<AssistantMessage | ToolResultMessage> {
return [
{
role: "assistant",
makeAgentAssistantMessage({
content: [
{
type: "toolCall",
@@ -22,20 +24,10 @@ function makeToolCallResultPairInput(): Array<AssistantMessage | ToolResultMessa
arguments: { path: "package.json" },
},
],
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: nextTimestamp(),
},
}),
{
role: "toolResult",
toolCallId: "call_123|fc_456",
@@ -47,6 +39,27 @@ function makeToolCallResultPairInput(): Array<AssistantMessage | ToolResultMessa
];
}
function makeEmptyAssistantErrorMessage(): AssistantMessage {
return makeAgentAssistantMessage({
stopReason: "error",
content: [],
model: "gpt-5.2",
timestamp: nextTimestamp(),
}) satisfies AssistantMessage;
}
function makeOpenAiResponsesAssistantMessage(
content: AssistantMessage["content"],
stopReason: AssistantMessage["stopReason"] = "toolUse",
): AssistantMessage {
return makeAgentAssistantMessage({
content,
model: "gpt-5.2",
stopReason,
timestamp: nextTimestamp(),
});
}
function expectToolCallAndResultIds(out: AgentMessage[], expectedId: string) {
const assistant = out[0];
expect(assistant.role).toBe("assistant");
@@ -95,23 +108,9 @@ describe("sanitizeSessionMessagesImages", () => {
it("does not synthesize tool call input when missing", async () => {
const input = castAgentMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read" }],
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: nextTimestamp(),
},
makeOpenAiResponsesAssistantMessage([
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
]),
]);
const out = await sanitizeSessionMessagesImages(input, "test");
@@ -124,26 +123,10 @@ describe("sanitizeSessionMessagesImages", () => {
it("removes empty assistant text blocks but preserves tool calls", async () => {
const input = castAgentMessages([
{
role: "assistant",
content: [
{ type: "text", text: "" },
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
],
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: nextTimestamp(),
},
makeOpenAiResponsesAssistantMessage([
{ type: "text", text: "" },
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
]),
]);
const out = await sanitizeSessionMessagesImages(input, "test");
@@ -189,33 +172,7 @@ describe("sanitizeSessionMessagesImages", () => {
});
it("sanitizes tool IDs in images-only mode when explicitly enabled", async () => {
const input = castAgentMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "call_123|fc_456", name: "read", arguments: {} }],
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: nextTimestamp(),
},
{
role: "toolResult",
toolCallId: "call_123|fc_456",
toolName: "read",
content: [{ type: "text", text: "ok" }],
isError: false,
timestamp: nextTimestamp(),
},
]);
const input = makeToolCallResultPairInput();
const out = await sanitizeSessionMessagesImages(input, "test", {
sanitizeMode: "images-only",
@@ -297,39 +254,11 @@ describe("sanitizeSessionMessagesImages", () => {
const input = castAgentMessages([
{ role: "user", content: "hello", timestamp: nextTimestamp() } satisfies UserMessage,
{
role: "assistant",
stopReason: "error",
content: [],
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: nextTimestamp(),
} satisfies AssistantMessage,
...makeEmptyAssistantErrorMessage(),
},
{
role: "assistant",
stopReason: "error",
content: [],
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: nextTimestamp(),
} satisfies AssistantMessage,
...makeEmptyAssistantErrorMessage(),
},
]);
const out = await sanitizeSessionMessagesImages(input, "test");

View File

@@ -1,35 +1,21 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
import type { ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import { describe, expect, it } from "vitest";
import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js";
import { sanitizeSessionHistory } from "./google.js";
function makeAssistantToolCall(timestamp: number): AssistantMessage {
return {
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "web_fetch", arguments: { url: "x" } }],
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp,
};
}
describe("sanitizeSessionHistory toolResult details stripping", () => {
it("strips toolResult.details so untrusted payloads are not fed back to the model", async () => {
const sm = SessionManager.inMemory();
const messages: AgentMessage[] = [
makeAssistantToolCall(1),
makeAgentAssistantMessage({
content: [{ type: "toolCall", id: "call_1", name: "web_fetch", arguments: { url: "x" } }],
model: "gpt-5.2",
stopReason: "toolUse",
timestamp: 1,
}),
{
role: "toolResult",
toolCallId: "call_1",

View File

@@ -1,6 +1,7 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js";
import {
truncateToolResultText,
truncateToolResultMessage,
@@ -35,23 +36,12 @@ function makeUserMessage(text: string): UserMessage {
}
function makeAssistantMessage(text: string): AssistantMessage {
return {
role: "assistant",
return makeAgentAssistantMessage({
content: [{ type: "text", text }],
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: nextTimestamp(),
};
});
}
describe("truncateToolResultText", () => {

View File

@@ -9,6 +9,7 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createBaseToolHandlerState } from "./pi-tool-handler-state.test-helpers.js";
const hookMocks = vi.hoisted(() => ({
runner: {
@@ -75,17 +76,7 @@ function createToolHandlerCtx() {
hookRunner: hookMocks.runner,
state: {
toolMetaById: new Map<string, unknown>(),
toolMetas: [] as Array<{ toolName?: string; meta?: string }>,
toolSummaryById: new Set<string>(),
lastToolError: undefined,
pendingMessagingTexts: new Map<string, string>(),
pendingMessagingTargets: new Map<string, unknown>(),
pendingMessagingMediaUrls: new Map<string, string[]>(),
messagingToolSentTexts: [] as string[],
messagingToolSentTextsNormalized: [] as string[],
messagingToolSentMediaUrls: [] as string[],
messagingToolSentTargets: [] as unknown[],
blockBuffer: "",
...createBaseToolHandlerState(),
successfulCronAdds: 0,
},
log: { debug: vi.fn(), warn: vi.fn() },
@@ -247,7 +238,10 @@ describe("after_tool_call fires exactly once in embedded runs", () => {
result: { content: [{ type: "text", text: "ok" }] },
});
expect(beforeToolCallMocks.consumeAdjustedParamsForToolCall).toHaveBeenCalledWith(toolCallId);
expect(beforeToolCallMocks.consumeAdjustedParamsForToolCall).toHaveBeenCalledWith(
toolCallId,
"integration-test",
);
const event = (hookMocks.runner.runAfterToolCall as ReturnType<typeof vi.fn>).mock
.calls[0]?.[0] as { params?: unknown } | undefined;
expect(event?.params).toEqual(adjusted);

View File

@@ -0,0 +1,15 @@
export function createBaseToolHandlerState() {
return {
toolMetas: [] as Array<{ toolName?: string; meta?: string }>,
toolSummaryById: new Set<string>(),
lastToolError: undefined,
pendingMessagingTexts: new Map<string, string>(),
pendingMessagingTargets: new Map<string, unknown>(),
pendingMessagingMediaUrls: new Map<string, string[]>(),
messagingToolSentTexts: [] as string[],
messagingToolSentTextsNormalized: [] as string[],
messagingToolSentMediaUrls: [] as string[],
messagingToolSentTargets: [] as unknown[],
blockBuffer: "",
};
}

View File

@@ -112,10 +112,12 @@ function createSlugBase(words = 2) {
return parts.join("-");
}
export function createSessionSlug(isTaken?: (id: string) => boolean): string {
const isIdTaken = isTaken ?? (() => false);
function createAvailableSlug(
words: number,
isIdTaken: (id: string) => boolean,
): string | undefined {
for (let attempt = 0; attempt < 12; attempt += 1) {
const base = createSlugBase(2);
const base = createSlugBase(words);
if (!isIdTaken(base)) {
return base;
}
@@ -126,17 +128,18 @@ export function createSessionSlug(isTaken?: (id: string) => boolean): string {
}
}
}
for (let attempt = 0; attempt < 12; attempt += 1) {
const base = createSlugBase(3);
if (!isIdTaken(base)) {
return base;
}
for (let i = 2; i <= 12; i += 1) {
const candidate = `${base}-${i}`;
if (!isIdTaken(candidate)) {
return candidate;
}
}
return undefined;
}
export function createSessionSlug(isTaken?: (id: string) => boolean): string {
const isIdTaken = isTaken ?? (() => false);
const twoWord = createAvailableSlug(2, isIdTaken);
if (twoWord) {
return twoWord;
}
const threeWord = createAvailableSlug(3, isIdTaken);
if (threeWord) {
return threeWord;
}
const fallback = `${createSlugBase(3)}-${Math.random().toString(36).slice(2, 5)}`;
return isIdTaken(fallback) ? `${fallback}-${Date.now().toString(36)}` : fallback;

View File

@@ -2,6 +2,7 @@ import type { Skill } from "@mariozechner/pi-coding-agent";
import { validateRegistryNpmSpec } from "../../infra/npm-registry-spec.js";
import { parseFrontmatterBlock } from "../../markdown/frontmatter.js";
import {
applyOpenClawManifestInstallCommonFields,
getFrontmatterString,
normalizeStringList,
parseOpenClawManifestInstallBase,
@@ -113,19 +114,12 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
return undefined;
}
const { raw } = parsed;
const spec: SkillInstallSpec = {
kind: parsed.kind as SkillInstallSpec["kind"],
};
if (parsed.id) {
spec.id = parsed.id;
}
if (parsed.label) {
spec.label = parsed.label;
}
if (parsed.bins) {
spec.bins = parsed.bins;
}
const spec = applyOpenClawManifestInstallCommonFields<SkillInstallSpec>(
{
kind: parsed.kind as SkillInstallSpec["kind"],
},
parsed,
);
const osList = normalizeStringList(raw.os);
if (osList.length > 0) {
spec.os = osList;

View File

@@ -1,20 +1,6 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, ToolResultMessage, Usage, UserMessage } from "@mariozechner/pi-ai";
const ZERO_USAGE: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
import { ZERO_USAGE_FIXTURE } from "./usage-fixtures.js";
export function castAgentMessage(message: unknown): AgentMessage {
return message as AgentMessage;
@@ -42,7 +28,7 @@ export function makeAgentAssistantMessage(
api: "openai-responses",
provider: "openai",
model: "test-model",
usage: ZERO_USAGE,
usage: ZERO_USAGE_FIXTURE,
stopReason: "stop",
timestamp: 0,
...overrides,

View File

@@ -1,19 +1,5 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
const ZERO_USAGE: AssistantMessage["usage"] = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
import { ZERO_USAGE_FIXTURE } from "./usage-fixtures.js";
export function makeAssistantMessageFixture(
overrides: Partial<AssistantMessage> = {},
@@ -24,7 +10,7 @@ export function makeAssistantMessageFixture(
api: "openai-responses",
provider: "openai",
model: "test-model",
usage: ZERO_USAGE,
usage: ZERO_USAGE_FIXTURE,
timestamp: 0,
stopReason: "error",
errorMessage: errorText,

View File

@@ -0,0 +1,16 @@
import type { Usage } from "@mariozechner/pi-ai";
export const ZERO_USAGE_FIXTURE: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};

21
src/agents/trace-base.ts Normal file
View File

@@ -0,0 +1,21 @@
export type AgentTraceBase = {
runId?: string;
sessionId?: string;
sessionKey?: string;
provider?: string;
modelId?: string;
modelApi?: string | null;
workspaceDir?: string;
};
export function buildAgentTraceBase(params: AgentTraceBase): AgentTraceBase {
return {
runId: params.runId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
provider: params.provider,
modelId: params.modelId,
modelApi: params.modelApi,
workspaceDir: params.workspaceDir,
};
}