feat: Paseo-like orchestrator Phase 1-2 — trace system, session persistence, timeline, run_command, auto-fix loop
Phase 1: Trace System + Observability - tool_traces DB table + insert/update service - tool_trace_start/tool_trace_finish WS frames (contracts + FE types) - Instrumented tool-phase.ts with timing around every tool call - GET /api/chats/:id/traces paginated endpoint - Trace viewer frontend (collapsible panel with timing bars + token breakdown) Phase 2: Session Persistence + Resume - agent_snapshots table (UPSERT per chat, persisted on turn boundaries) - save/load/delete service functions - Agent snapshot sent on WS reconnect - Session timeline view (vertical timeline with scroll-to + restore) Tooling: - run_command tool (execFile, 30s timeout, 32KB cap, path-guarded) - Auto-fix loop: after write tools, runs pnpm build, injects errors into next turn
This commit is contained in:
@@ -18,8 +18,10 @@ import { registerCoderProxy } from './routes/coder-proxy.js';
|
||||
import { registerModelRoutes } from './routes/models.js';
|
||||
import { registerAgentRoutes } from './routes/agents.js';
|
||||
import { registerSkillsRoutes } from './routes/skills.js';
|
||||
import { registerTraceRoutes } from './routes/traces.js';
|
||||
import { registerToolsRoutes } from './routes/tools.js';
|
||||
import { registerAnalyticsRoutes } from './routes/analytics.js';
|
||||
import { registerMemoryRoutes } from './routes/memory.js';
|
||||
import { registerInferenceSettingsRoutes } from './routes/inference-settings.js';
|
||||
import { createInferenceRunner } from './services/inference/index.js';
|
||||
import { createBroker } from './services/broker.js';
|
||||
@@ -124,8 +126,10 @@ async function main() {
|
||||
registerAgentRoutes(app, sql);
|
||||
registerSidebarRoutes(app, sql);
|
||||
registerChatRoutes(app, sql, broker);
|
||||
registerTraceRoutes(app, sql);
|
||||
registerToolsRoutes(app, sql);
|
||||
registerAnalyticsRoutes(app, sql);
|
||||
registerMemoryRoutes(app, sql);
|
||||
registerInferenceSettingsRoutes(app);
|
||||
|
||||
// Batch 9.6: warm the skills cache at boot and surface the count. Empty or
|
||||
|
||||
38
apps/server/src/routes/traces.ts
Normal file
38
apps/server/src/routes/traces.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { ToolTrace } from '../services/tool-traces.js';
|
||||
|
||||
export function registerTraceRoutes(app: FastifyInstance, sql: Sql): void {
|
||||
app.get<{ Params: { id: string }; Querystring: { limit?: string; offset?: string } }>(
|
||||
'/api/chats/:id/traces',
|
||||
async (req, reply) => {
|
||||
const chat = await sql`SELECT id FROM chats WHERE id = ${req.params.id}`;
|
||||
if (chat.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'chat not found' };
|
||||
}
|
||||
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 200);
|
||||
const offset = Math.max(Number(req.query.offset) || 0, 0);
|
||||
|
||||
const rows = await sql<ToolTrace[]>`
|
||||
SELECT * FROM tool_traces
|
||||
WHERE chat_id = ${req.params.id}
|
||||
ORDER BY started_at ASC
|
||||
LIMIT ${limit}
|
||||
OFFSET ${offset}
|
||||
`;
|
||||
|
||||
const [countRow] = await sql<{ count: number }[]>`
|
||||
SELECT count(*)::int AS count FROM tool_traces WHERE chat_id = ${req.params.id}
|
||||
`;
|
||||
|
||||
return {
|
||||
data: rows,
|
||||
total: countRow?.count ?? 0,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Sql } from '../db.js';
|
||||
import type { Broker } from '../services/broker.js';
|
||||
import type { Message } from '../types/api.js';
|
||||
import { MESSAGE_COLUMNS } from '../services/message-columns.js';
|
||||
import { loadAgentSnapshot } from '../services/session-snapshots.js';
|
||||
|
||||
export function registerWebSocket(
|
||||
app: FastifyInstance,
|
||||
@@ -33,6 +34,24 @@ export function registerWebSocket(
|
||||
`;
|
||||
socket.send(JSON.stringify({ type: 'snapshot', messages }));
|
||||
|
||||
// v2.7.x: on reconnect, restore agent snapshot state so the frontend
|
||||
// knows there's an ongoing agent turn. Best-effort per chat; most
|
||||
// sessions won't have any snapshots.
|
||||
const chats = await sql<{ id: string }[]>`SELECT id FROM chats WHERE session_id = ${sessionId}`;
|
||||
for (const chat of chats) {
|
||||
const agentSnapshot = await loadAgentSnapshot(sql, chat.id).catch(() => null);
|
||||
if (agentSnapshot) {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'agent_snapshot',
|
||||
chat_id: chat.id,
|
||||
agent: agentSnapshot.agent,
|
||||
model: agentSnapshot.model,
|
||||
mode: agentSnapshot.mode,
|
||||
turn_number: agentSnapshot.turn_number,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribe = broker.subscribe(sessionId, (frame) => {
|
||||
if (socket.readyState !== socket.OPEN) return;
|
||||
try {
|
||||
|
||||
@@ -414,3 +414,55 @@ END $$;
|
||||
|
||||
-- Remove the v2.0.5 arena_id column (replaced by the new Arena feature).
|
||||
ALTER TABLE tasks DROP COLUMN IF EXISTS arena_id;
|
||||
|
||||
-- v2.x-tool-traces: per-call tool execution records for observability.
|
||||
CREATE TABLE IF NOT EXISTS tool_traces (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
|
||||
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
turn_number INTEGER NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
tool_input JSONB NOT NULL,
|
||||
tool_output TEXT,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||
finished_at TIMESTAMPTZ,
|
||||
latency_ms INTEGER,
|
||||
tokens_used INTEGER,
|
||||
cache_tokens INTEGER,
|
||||
reasoning_tokens INTEGER,
|
||||
error TEXT,
|
||||
outcome TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_traces_chat ON tool_traces(chat_id, created_at);
|
||||
|
||||
-- v2.x-tool-traces: active tool call state for in-flight instrumentation.
|
||||
CREATE TABLE IF NOT EXISTS tool_trace_states (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
|
||||
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
turn_number INTEGER NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
tool_input JSONB NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
|
||||
-- agent_snapshots: persistent agent session state for cross-refresh resume.
|
||||
CREATE TABLE IF NOT EXISTS agent_snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
|
||||
model TEXT NOT NULL,
|
||||
agent TEXT,
|
||||
mode TEXT,
|
||||
turn_number INTEGER NOT NULL DEFAULT 0,
|
||||
messages JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
tool_states JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_snapshots_chat ON agent_snapshots(chat_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_snapshots_chat_unique ON agent_snapshots(chat_id);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { resolveGrantRoot } from '../grant_resolver.js';
|
||||
import { stripToolMarkup } from './tool-call-parser.js';
|
||||
import { repairToolInput } from './tool-input-repair.js';
|
||||
import type { FailureKind } from './mistake-tracker.js';
|
||||
import { insertToolTrace, updateToolTrace } from '../tool-traces.js';
|
||||
import type {
|
||||
InferenceContext,
|
||||
StreamResult,
|
||||
@@ -175,6 +176,7 @@ export async function executeToolPhase(
|
||||
session: Session,
|
||||
projectRoot: string,
|
||||
agent?: Agent | null,
|
||||
turnNumber?: number,
|
||||
): Promise<ToolPhaseResult> {
|
||||
const { sessionId, chatId, assistantMessageId } = args;
|
||||
const content = stripToolMarkup(result.content, { final: true });
|
||||
@@ -378,11 +380,53 @@ export async function executeToolPhase(
|
||||
});
|
||||
return;
|
||||
}
|
||||
// tool_trace instrumentation - start
|
||||
const traceId = crypto.randomUUID();
|
||||
const traceStartTime = Date.now();
|
||||
const startedAtIso = new Date().toISOString();
|
||||
insertToolTrace(ctx.sql, {
|
||||
session_id: sessionId,
|
||||
chat_id: chatId,
|
||||
message_id: assistantMessageId,
|
||||
turn_number: turnNumber ?? 0,
|
||||
tool_name: tc.name,
|
||||
tool_input: tc.args as Record<string, unknown>,
|
||||
}).catch(() => {});
|
||||
ctx.publish(sessionId, {
|
||||
type: 'tool_trace_start',
|
||||
trace_id: traceId,
|
||||
message_id: assistantMessageId,
|
||||
chat_id: chatId,
|
||||
tool_name: tc.name,
|
||||
tool_input: tc.args as Record<string, unknown>,
|
||||
started_at: startedAtIso,
|
||||
});
|
||||
const tres = await executeToolCall(
|
||||
projectRoot, tc, session.allowed_read_paths,
|
||||
{ sql: ctx.sql, sessionId },
|
||||
ctx.hooks, sessionId,
|
||||
);
|
||||
// tool_trace instrumentation - finish
|
||||
const finishedAtIso = new Date().toISOString();
|
||||
const latencyMs = Date.now() - traceStartTime;
|
||||
updateToolTrace(ctx.sql, traceId, {
|
||||
finished_at: finishedAtIso,
|
||||
...(tres.outcome === 'success' && tres.output != null ? { tool_output: JSON.stringify(tres.output) } : {}),
|
||||
latency_ms: latencyMs,
|
||||
outcome: tres.outcome,
|
||||
...(tres.error ? { error: tres.error } : {}),
|
||||
}).catch(() => {});
|
||||
ctx.publish(sessionId, {
|
||||
type: 'tool_trace_finish',
|
||||
trace_id: traceId,
|
||||
message_id: assistantMessageId,
|
||||
chat_id: chatId,
|
||||
tool_name: tc.name,
|
||||
finished_at: finishedAtIso,
|
||||
outcome: tres.outcome,
|
||||
latency_ms: latencyMs,
|
||||
...(tres.error ? { error: tres.error } : {}),
|
||||
});
|
||||
// vWhale: PostToolUse hook (best-effort, non-blocking).
|
||||
if (ctx.hooks) {
|
||||
ctx.hooks.run('PostToolUse', {
|
||||
|
||||
@@ -37,6 +37,12 @@ import type {
|
||||
StreamResult,
|
||||
TurnArgs,
|
||||
} from './types.js';
|
||||
import { saveAgentSnapshot } from '../session-snapshots.js';
|
||||
// vWhale: auto-fix loop — after write tools, build the project and inject
|
||||
// errors. Uses execFile (no shell) against the project root.
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
runCapHitSummary,
|
||||
runDoomLoopSummary,
|
||||
@@ -44,6 +50,71 @@ import {
|
||||
insertMistakeRecoverySentinel,
|
||||
} from './sentinel-summaries.js';
|
||||
|
||||
// vWhale: auto-fix — detect build command from package.json, run it, return
|
||||
// error text for injection into next iteration. Best-effort, never throws.
|
||||
const BUILD_TIMEOUT_MS = 60_000;
|
||||
const BUILD_OUTPUT_CAP = 8_000;
|
||||
|
||||
async function detectAndRunBuild(
|
||||
ctx: InferenceContext,
|
||||
projectRoot: string,
|
||||
sessionId: string,
|
||||
chatId: string,
|
||||
model: string,
|
||||
existingNote: string | undefined,
|
||||
): Promise<string | undefined> {
|
||||
// Only run for DeepSeek models (local Qwen models don't benefit from build loop).
|
||||
if (!model.startsWith('deepseek-')) return undefined;
|
||||
|
||||
// Detect build command from package.json in project root.
|
||||
const pkgPath = join(projectRoot, 'package.json');
|
||||
if (!existsSync(pkgPath)) return undefined;
|
||||
|
||||
let buildCmd: string | null = null;
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { scripts?: Record<string, string> };
|
||||
if (pkg.scripts?.build) buildCmd = 'build';
|
||||
else if (pkg.scripts?.compile) buildCmd = 'compile';
|
||||
else if (pkg.scripts?.typecheck) buildCmd = 'typecheck';
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!buildCmd) return undefined;
|
||||
|
||||
// Detect package manager.
|
||||
const hasPnpm = existsSync(join(projectRoot, 'pnpm-lock.yaml'));
|
||||
const hasYarn = existsSync(join(projectRoot, 'yarn.lock'));
|
||||
const pm = hasPnpm ? 'pnpm' : hasYarn ? 'yarn' : 'npm';
|
||||
|
||||
// Run the build.
|
||||
try {
|
||||
const out = await new Promise<string>((resolve, reject) => {
|
||||
execFile(pm, ['run', buildCmd!], { cwd: projectRoot, timeout: BUILD_TIMEOUT_MS, maxBuffer: BUILD_OUTPUT_CAP * 2 },
|
||||
(err, stdout, stderr) => {
|
||||
if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
resolve(''); // package manager not found — skip
|
||||
return;
|
||||
}
|
||||
const merged = (stdout + '\n' + stderr).trim();
|
||||
resolve(merged.slice(0, BUILD_OUTPUT_CAP));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (!out) return undefined; // build succeeded or no output
|
||||
ctx.log.info({ sessionId, chatId, buildCmd, outputLen: out.length }, 'auto-fix: build failed');
|
||||
|
||||
// Truncate if existing note exists
|
||||
const combined = existingNote
|
||||
? existingNote + '\n\n--- Build error ---\n' + out.slice(0, BUILD_OUTPUT_CAP - existingNote.length)
|
||||
: '--- Build error ---\n' + out.slice(0, BUILD_OUTPUT_CAP);
|
||||
|
||||
return combined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// P5: MAX_STEPS moved to ./turn-config.ts (with resolveTurnConfig). Re-exported
|
||||
// here so the public surface (index.ts → './turn.js') is unchanged.
|
||||
export { MAX_STEPS } from './turn-config.js';
|
||||
@@ -240,7 +311,7 @@ export async function runAssistantTurn(
|
||||
// ---- tool phase ----
|
||||
let toolPhaseResult: ToolPhaseResult;
|
||||
try {
|
||||
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot, agent);
|
||||
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot, agent, stepNumber);
|
||||
} catch (err) {
|
||||
// Tool phase errors are unexpected (individual tool failures are
|
||||
// caught inside executeToolPhase). Log and break.
|
||||
@@ -260,6 +331,17 @@ export async function runAssistantTurn(
|
||||
recordStep(mistakeTracker, o);
|
||||
}
|
||||
|
||||
// vWhale: auto-fix — after write tools, attempt build and inject errors.
|
||||
const WRITE_TOOLS = new Set(['edit_file', 'create_file', 'delete_file', 'apply_pending']);
|
||||
const hasWriteTools = toolPhaseResult.toolCalls.some((tc) => WRITE_TOOLS.has(tc.name));
|
||||
if (hasWriteTools) {
|
||||
detectAndRunBuild(ctx, projectRoot, sessionId, chatId, iterSession.model, pendingRecoveryNote)
|
||||
.then((buildError) => {
|
||||
if (buildError) pendingRecoveryNote = buildError;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// v#12 MistakeTracker: post-tool decision (pure). 'stop' = the tool phase
|
||||
// returned a non-'continue' action ('paused' for user input, or
|
||||
// 'synthesis_done') — neither a nudge nor an escalate would change the
|
||||
@@ -336,6 +418,19 @@ export async function runAssistantTurn(
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// ---- persist agent snapshot (best-effort, never blocks inference) ----
|
||||
const snapLoaded = await loadContext(ctx.sql, sessionId, chatId).catch(() => null);
|
||||
if (snapLoaded) {
|
||||
await saveAgentSnapshot(ctx.sql, chatId, {
|
||||
session_id: sessionId,
|
||||
model: snapLoaded.session.model,
|
||||
agent: agent?.name ?? null,
|
||||
mode: null,
|
||||
turn_number: stepNumber,
|
||||
messages: snapLoaded.history.map((m) => ({ role: m.role, content: m.content })),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// ---- post-loop: step-cap sentinel ----
|
||||
// When the loop exits because stepNumber reached effectiveCap, the last
|
||||
// iteration's tool phase returned 'continue' with a nextAssistantId that
|
||||
|
||||
@@ -46,6 +46,9 @@ export interface InferenceFrame {
|
||||
| 'error'
|
||||
| 'flow_run_started'
|
||||
| 'flow_run_step_updated'
|
||||
// tool trace frames
|
||||
| 'tool_trace_start'
|
||||
| 'tool_trace_finish'
|
||||
// arena frames
|
||||
| 'battle_started'
|
||||
| 'contestant_updated'
|
||||
@@ -82,6 +85,15 @@ export interface InferenceFrame {
|
||||
reasoning_tokens?: number | null;
|
||||
session_id?: string;
|
||||
name?: string;
|
||||
// tool trace frames
|
||||
trace_id?: string;
|
||||
tool_name?: string;
|
||||
tool_input?: Record<string, unknown>;
|
||||
tool_output?: string | null;
|
||||
latency_ms?: number;
|
||||
outcome?: string;
|
||||
// agent snapshot restore
|
||||
agent?: string | null;
|
||||
// orchestrator frames ([D-6])
|
||||
run_id?: string;
|
||||
flow_name?: string;
|
||||
|
||||
51
apps/server/src/services/session-snapshots.ts
Normal file
51
apps/server/src/services/session-snapshots.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Sql } from '../db.js';
|
||||
|
||||
export interface AgentSnapshot {
|
||||
id: string;
|
||||
session_id: string;
|
||||
chat_id: string;
|
||||
model: string;
|
||||
agent: string | null;
|
||||
mode: string | null;
|
||||
turn_number: number;
|
||||
messages: unknown[];
|
||||
tool_states: unknown[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Save or update the agent snapshot for a chat (UPSERT). */
|
||||
export async function saveAgentSnapshot(sql: Sql, chatId: string, data: {
|
||||
session_id: string;
|
||||
model: string;
|
||||
agent?: string | null;
|
||||
mode?: string | null;
|
||||
turn_number: number;
|
||||
messages: unknown[];
|
||||
tool_states?: unknown[];
|
||||
}): Promise<void> {
|
||||
await sql`
|
||||
INSERT INTO agent_snapshots (session_id, chat_id, model, agent, mode, turn_number, messages, tool_states, updated_at)
|
||||
VALUES (${data.session_id}, ${chatId}, ${data.model}, ${data.agent ?? null}, ${data.mode ?? null}, ${data.turn_number}, ${sql.json(data.messages as never)}, ${sql.json((data.tool_states ?? []) as never)}, clock_timestamp())
|
||||
ON CONFLICT (chat_id)
|
||||
DO UPDATE SET
|
||||
model = EXCLUDED.model,
|
||||
agent = EXCLUDED.agent,
|
||||
mode = EXCLUDED.mode,
|
||||
turn_number = EXCLUDED.turn_number,
|
||||
messages = EXCLUDED.messages,
|
||||
tool_states = EXCLUDED.tool_states,
|
||||
updated_at = clock_timestamp()
|
||||
`;
|
||||
}
|
||||
|
||||
/** Load the agent snapshot for a chat. Returns null if no snapshot exists. */
|
||||
export async function loadAgentSnapshot(sql: Sql, chatId: string): Promise<AgentSnapshot | null> {
|
||||
const rows = await sql<AgentSnapshot[]>`SELECT * FROM agent_snapshots WHERE chat_id = ${chatId}`;
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/** Delete the agent snapshot for a chat (call when session ends). */
|
||||
export async function deleteAgentSnapshot(sql: Sql, chatId: string): Promise<void> {
|
||||
await sql`DELETE FROM agent_snapshots WHERE chat_id = ${chatId}`;
|
||||
}
|
||||
92
apps/server/src/services/tool-traces.ts
Normal file
92
apps/server/src/services/tool-traces.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { Sql } from '../db.js';
|
||||
|
||||
export interface ToolTrace {
|
||||
id: string;
|
||||
session_id: string;
|
||||
chat_id: string;
|
||||
message_id: string | null;
|
||||
turn_number: number;
|
||||
tool_name: string;
|
||||
tool_input: unknown;
|
||||
tool_output: string | null;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
latency_ms: number | null;
|
||||
tokens_used: number | null;
|
||||
cache_tokens: number | null;
|
||||
reasoning_tokens: number | null;
|
||||
error: string | null;
|
||||
outcome: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ToolTraceInsert {
|
||||
session_id: string;
|
||||
chat_id: string;
|
||||
message_id: string | null;
|
||||
turn_number: number;
|
||||
tool_name: string;
|
||||
tool_input: unknown;
|
||||
outcome?: string;
|
||||
}
|
||||
|
||||
export interface ToolTraceUpdate {
|
||||
finished_at?: string;
|
||||
latency_ms?: number;
|
||||
tool_output?: string;
|
||||
tokens_used?: number;
|
||||
cache_tokens?: number;
|
||||
reasoning_tokens?: number;
|
||||
error?: string;
|
||||
outcome?: string;
|
||||
}
|
||||
|
||||
export async function insertToolTrace(
|
||||
sql: Sql,
|
||||
insert: ToolTraceInsert,
|
||||
): Promise<ToolTrace> {
|
||||
const [row] = await sql<ToolTrace[]>`
|
||||
INSERT INTO tool_traces (
|
||||
session_id, chat_id, message_id, turn_number,
|
||||
tool_name, tool_input, outcome
|
||||
) VALUES (
|
||||
${insert.session_id}, ${insert.chat_id}, ${insert.message_id},
|
||||
${insert.turn_number}, ${insert.tool_name},
|
||||
${sql.json(insert.tool_input as never)},
|
||||
${insert.outcome ?? null}
|
||||
)
|
||||
RETURNING *
|
||||
`;
|
||||
if (!row) throw new Error('insertToolTrace returned no row');
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function updateToolTrace(
|
||||
sql: Sql,
|
||||
id: string,
|
||||
updates: ToolTraceUpdate,
|
||||
): Promise<ToolTrace | null> {
|
||||
const cols: string[] = [];
|
||||
const vals: any[] = [];
|
||||
|
||||
if (updates.finished_at !== undefined) { cols.push('finished_at'); vals.push(updates.finished_at); }
|
||||
if (updates.latency_ms !== undefined) { cols.push('latency_ms'); vals.push(updates.latency_ms); }
|
||||
if (updates.tool_output !== undefined) { cols.push('tool_output'); vals.push(updates.tool_output); }
|
||||
if (updates.tokens_used !== undefined) { cols.push('tokens_used'); vals.push(updates.tokens_used); }
|
||||
if (updates.cache_tokens !== undefined) { cols.push('cache_tokens'); vals.push(updates.cache_tokens); }
|
||||
if (updates.reasoning_tokens !== undefined) { cols.push('reasoning_tokens'); vals.push(updates.reasoning_tokens); }
|
||||
if (updates.error !== undefined) { cols.push('error'); vals.push(updates.error); }
|
||||
if (updates.outcome !== undefined) { cols.push('outcome'); vals.push(updates.outcome); }
|
||||
|
||||
if (cols.length === 0) {
|
||||
const [row] = await sql<ToolTrace[]>`SELECT * FROM tool_traces WHERE id = ${id}`;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
const setClause = cols.map((c, i) => `${c} = $${i + 1}`).join(', ');
|
||||
const [row] = await sql.unsafe<ToolTrace[]>(
|
||||
`UPDATE tool_traces SET ${setClause} WHERE id = $${cols.length + 1} RETURNING *`,
|
||||
[...vals, id],
|
||||
);
|
||||
return row ?? null;
|
||||
}
|
||||
132
apps/server/src/services/tools/execute-command.ts
Normal file
132
apps/server/src/services/tools/execute-command.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* vWhale: run_command tool. Executes a shell command in the project worktree
|
||||
* and returns stdout/stderr. Only the project root is accessible as working
|
||||
* directory — path_guard enforces the scope.
|
||||
*
|
||||
* Security model:
|
||||
* - Uses execFile (no shell) — no shell injection, no pipe/redirect/env expansion.
|
||||
* - args passed as array, never a string.
|
||||
* - 30s timeout default, configure per-call.
|
||||
* - 32KB output cap with truncation (same pattern as web_fetch.ts).
|
||||
* - Working directory restricted to project root via path_guard.
|
||||
* - No background processes allowed (waits for completion).
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../tools.js';
|
||||
|
||||
const RunCommandInput = z.object({
|
||||
command: z.string().min(1).max(256),
|
||||
args: z.array(z.string()).default([]),
|
||||
description: z.string().max(256).optional(),
|
||||
timeout_ms: z.number().int().positive().max(120_000).optional(),
|
||||
});
|
||||
export type RunCommandInputT = z.infer<typeof RunCommandInput>;
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const MAX_OUTPUT_CHARS = 32_000;
|
||||
|
||||
export type RunCommandOutput =
|
||||
| {
|
||||
command: string;
|
||||
args: string[];
|
||||
exit_code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
truncated: boolean;
|
||||
duration_ms: number;
|
||||
}
|
||||
| {
|
||||
error: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export async function executeRunCommand(
|
||||
input: RunCommandInputT,
|
||||
projectRoot: string,
|
||||
): Promise<RunCommandOutput> {
|
||||
const timeoutMs = input.timeout_ms ?? DEFAULT_TIMEOUT_MS;
|
||||
const startTime = Date.now();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = execFile(
|
||||
input.command,
|
||||
input.args,
|
||||
{
|
||||
cwd: projectRoot,
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: MAX_OUTPUT_CHARS * 2,
|
||||
env: { ...process.env },
|
||||
},
|
||||
(err, stdout, stderr) => {
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
// Truncate output if needed
|
||||
const truncated = stdout.length + stderr.length > MAX_OUTPUT_CHARS;
|
||||
const cappedStdout = truncated ? stdout.slice(0, MAX_OUTPUT_CHARS) : stdout;
|
||||
const cappedStderr = truncated ? stderr.slice(0, Math.max(MAX_OUTPUT_CHARS - cappedStdout.length, 0)) : stderr;
|
||||
|
||||
const exitCode = err?.code === 'ENOENT' ? -1 : (err as Error & { code?: number })?.code ?? 0;
|
||||
|
||||
resolve({
|
||||
command: input.command,
|
||||
args: input.args,
|
||||
exit_code: typeof exitCode === 'number' ? exitCode : 1,
|
||||
stdout: cappedStdout,
|
||||
stderr: cappedStderr,
|
||||
truncated,
|
||||
duration_ms: durationMs,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export const runCommand: ToolDef<RunCommandInputT> = {
|
||||
name: 'run_command',
|
||||
description:
|
||||
'Run a shell command in the project workspace and return stdout + stderr. ' +
|
||||
'The command runs in the project root directory. ' +
|
||||
'Use for: building, testing, linting, git operations, running scripts. ' +
|
||||
'Output is capped at 32KB. Timeout defaults to 30s (max 120s). ' +
|
||||
'Security: args are passed as array (no shell injection). No background processes.',
|
||||
inputSchema: RunCommandInput as unknown as z.ZodType<RunCommandInputT>,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'run_command',
|
||||
description:
|
||||
'Execute a command in the project workspace. ' +
|
||||
'Use for builds, tests, linting, git commands, and scripts. ' +
|
||||
'The process runs with a 30s timeout and 32KB output cap.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: {
|
||||
type: 'string',
|
||||
description: 'Command to execute (e.g. pnpm, npm, npx, node, git, ls, cat).',
|
||||
},
|
||||
args: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Arguments as array (e.g. ["run", "build"]). Never embedded in a shell string.',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'Optional human-readable description of what this command does.',
|
||||
},
|
||||
timeout_ms: {
|
||||
type: 'integer',
|
||||
description: 'Timeout in milliseconds. Default 30000, max 120000.',
|
||||
},
|
||||
},
|
||||
required: ['command'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
return await executeRunCommand(input, projectRoot);
|
||||
},
|
||||
};
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
getCodeImpact,
|
||||
getTypeInfo,
|
||||
getCodeMap,
|
||||
getWikiArticle,
|
||||
} from './codecontext/index.js';
|
||||
// v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired
|
||||
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
|
||||
@@ -31,6 +32,14 @@ import { requestReadAccess } from '../request_read_access.js';
|
||||
// v2.6.x: read-only tool that reads a tab's transcript by its session-scoped
|
||||
// tab number. Needs DB/session context (ToolExecCtx 4th arg).
|
||||
import { readTabByNumber } from '../read_tab_by_number.js';
|
||||
// v2.x: memory management tools. file-based store with optional CoreTier
|
||||
// (SQLite FTS5 + vector) hybrid search backend.
|
||||
import { extractMemoryTool } from './extract_memory.js';
|
||||
import { manageMemoryTool } from './manage_memory.js';
|
||||
import { searchMemoryTool } from './search_memory.js';
|
||||
// vWhale: command execution tool. Spawns processes in the project worktree
|
||||
// with timeout and output cap. No shell — args are passed as array.
|
||||
import { runCommand } from './execute-command.js';
|
||||
|
||||
// v1.13.3: alpha-sorted by tool.name at module load. llama.cpp's prompt
|
||||
// cache hits on byte-identical prefixes; the tool list lives near the top
|
||||
@@ -85,6 +94,17 @@ export let ALL_TOOLS: ToolDef<unknown>[] = [
|
||||
getCodeImpact as ToolDef<unknown>,
|
||||
getTypeInfo as ToolDef<unknown>,
|
||||
getCodeMap as ToolDef<unknown>,
|
||||
// v2.8.14-domain2-phase3: wiki mode + token-efficient scanning.
|
||||
getWikiArticle as ToolDef<unknown>,
|
||||
// v2.x: memory management tools. File-based store with optional CoreTier
|
||||
// (SQLite FTS5 + vector) hybrid search backend.
|
||||
extractMemoryTool as ToolDef<unknown>,
|
||||
manageMemoryTool as ToolDef<unknown>,
|
||||
searchMemoryTool as ToolDef<unknown>,
|
||||
// vWhale: command execution. Spawns processes in the project worktree.
|
||||
// Read-write; use with guard: restricted to project root via path_guard,
|
||||
// no shell injection (execFile, not exec).
|
||||
runCommand as ToolDef<unknown>,
|
||||
].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
||||
|
||||
Reference in New Issue
Block a user