profclaw 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +5 -2
- package/config/pricing.yml +26 -2
- package/dist/agents/command-sandbox.d.ts +93 -0
- package/dist/agents/command-sandbox.js +235 -0
- package/dist/agents/executor-runner.d.ts +135 -0
- package/dist/agents/executor-runner.js +303 -0
- package/dist/agents/executor.d.ts +2 -0
- package/dist/agents/executor.js +35 -2
- package/dist/agents/output-truncator.d.ts +59 -0
- package/dist/agents/output-truncator.js +128 -0
- package/dist/agents/receipt/receipt-cli.d.ts +43 -0
- package/dist/agents/receipt/receipt-cli.js +82 -0
- package/dist/agents/receipt/receipt-types.d.ts +136 -0
- package/dist/agents/receipt/receipt-types.js +19 -0
- package/dist/agents/receipt/receipt-verify.d.ts +19 -0
- package/dist/agents/receipt/receipt-verify.js +176 -0
- package/dist/agents/receipt/receipt-view.d.ts +29 -0
- package/dist/agents/receipt/receipt-view.js +227 -0
- package/dist/agents/receipt/receipt-writer.d.ts +47 -0
- package/dist/agents/receipt/receipt-writer.js +186 -0
- package/dist/agents/result-store.d.ts +6 -0
- package/dist/agents/result-store.js +43 -2
- package/dist/agents/session-diff.d.ts +6 -0
- package/dist/agents/session-diff.js +10 -19
- package/dist/agents/tool-selector.d.ts +123 -0
- package/dist/agents/tool-selector.js +321 -0
- package/dist/agents/verified-run-git.d.ts +49 -0
- package/dist/agents/verified-run-git.js +82 -0
- package/dist/agents/verified-run.d.ts +149 -0
- package/dist/agents/verified-run.js +402 -0
- package/dist/agents/verifier.d.ts +64 -0
- package/dist/agents/verifier.js +186 -0
- package/dist/agents/worktree-tools.d.ts +47 -0
- package/dist/agents/worktree-tools.js +246 -0
- package/dist/chat/agentic-executor.js +14 -6
- package/dist/chat/execution/sandbox.d.ts +8 -0
- package/dist/chat/execution/sandbox.js +25 -1
- package/dist/chat/execution/tools/tts-speak.d.ts +2 -2
- package/dist/chat/meta-tool-handlers.d.ts +18 -0
- package/dist/chat/meta-tool-handlers.js +51 -0
- package/dist/chat/system-prompts.js +6 -1
- package/dist/chat/tool-handler.d.ts +2 -0
- package/dist/chat/tool-handler.js +47 -3
- package/dist/cli/commands/receipt.d.ts +7 -0
- package/dist/cli/commands/receipt.js +90 -0
- package/dist/cli/commands/run.d.ts +28 -0
- package/dist/cli/commands/run.js +204 -0
- package/dist/cli/index.js +4 -0
- package/dist/costs/token-tracker.d.ts +10 -1
- package/dist/costs/token-tracker.js +37 -5
- package/dist/providers/ai-sdk.js +33 -37
- package/dist/providers/core/models.js +59 -17
- package/dist/providers/core/types.d.ts +4 -0
- package/dist/providers/prompt-cache.d.ts +68 -0
- package/dist/providers/prompt-cache.js +165 -0
- package/dist/providers/smart-router.js +11 -3
- package/dist/routes/chat.js +22 -5
- package/dist/server.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExecutorRunner: the built-in model-backed AgentRunner for verified runs.
|
|
3
|
+
*
|
|
4
|
+
* Adapts AgentExecutor to the AgentRunner seam used by runVerifiedGoal:
|
|
5
|
+
* - runs the executor with the run's worktree as the only place it can touch
|
|
6
|
+
* (worktree-confined tools plus the PermissionManager, never push or PR);
|
|
7
|
+
* - feeds it the goal plus the previous attempt's trimmed verifier output;
|
|
8
|
+
* - reports tokens, dollars and cache hit rate so the loop's budget guard works,
|
|
9
|
+
* and cancels an attempt mid-flight when the remaining budget is spent;
|
|
10
|
+
* - cascades models: starts on the smart router's cheap pick and moves one
|
|
11
|
+
* tier up after N consecutive verifier failures (N is configurable).
|
|
12
|
+
*
|
|
13
|
+
* AgentExecutor is used as-is. Cache usage is read from its public
|
|
14
|
+
* `step:complete` event, so the executor internals are not touched.
|
|
15
|
+
*/
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
17
|
+
import { AgentExecutor } from './executor.js';
|
|
18
|
+
import { createRunPermissionManager, createWorktreeToolset } from './worktree-tools.js';
|
|
19
|
+
import { classifyComplexity, selectModel } from '../providers/smart-router.js';
|
|
20
|
+
import { getModelInfo, resolveModelAlias } from '../providers/core/models.js';
|
|
21
|
+
import { extractCacheUsage, getCacheConfigForModel } from '../providers/prompt-cache.js';
|
|
22
|
+
const TIER_ORDER = ['trivial', 'standard', 'complex'];
|
|
23
|
+
function toChoice(spec, tier) {
|
|
24
|
+
const resolved = resolveModelAlias(spec);
|
|
25
|
+
const modelId = resolved?.model ?? spec;
|
|
26
|
+
const info = getModelInfo(modelId);
|
|
27
|
+
const provider = resolved?.provider ?? info?.provider;
|
|
28
|
+
if (!provider) {
|
|
29
|
+
throw new Error(`Unknown model "${spec}". Use an alias, a catalog id, or provider/model.`);
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
provider,
|
|
33
|
+
model: modelId,
|
|
34
|
+
costPer1MInput: info?.costPer1MInput ?? 0,
|
|
35
|
+
costPer1MOutput: info?.costPer1MOutput ?? 0,
|
|
36
|
+
tier,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Build the ordered list of models an attempt series may use.
|
|
41
|
+
*
|
|
42
|
+
* `modelSpec` (comma separated) pins an explicit ladder, cheapest first.
|
|
43
|
+
* Without it the smart router classifies the goal, picks the cheap model for
|
|
44
|
+
* that tier, and appends the picks for each higher tier (deduplicated).
|
|
45
|
+
*/
|
|
46
|
+
export function buildModelLadder(goal, availableProviders, modelSpec) {
|
|
47
|
+
const specs = (modelSpec ?? '').split(',').map((s) => s.trim()).filter((s) => s.length > 0);
|
|
48
|
+
if (specs.length > 0)
|
|
49
|
+
return specs.map((s) => toChoice(s, 'pinned'));
|
|
50
|
+
if (availableProviders.size === 0) {
|
|
51
|
+
throw new Error('No model provider is configured. Set a provider API key or pass --model.');
|
|
52
|
+
}
|
|
53
|
+
const complexity = classifyComplexity(goal, { hasCodeContext: true, hasToolUse: true });
|
|
54
|
+
const startIndex = TIER_ORDER.indexOf(complexity.tier);
|
|
55
|
+
const ladder = [];
|
|
56
|
+
for (const tier of TIER_ORDER.slice(startIndex)) {
|
|
57
|
+
const picked = selectModel({ ...complexity, tier }, availableProviders).selectedModel;
|
|
58
|
+
if (!picked.supportsTools)
|
|
59
|
+
continue;
|
|
60
|
+
if (ladder.some((c) => c.model === picked.id))
|
|
61
|
+
continue;
|
|
62
|
+
ladder.push({
|
|
63
|
+
provider: picked.provider,
|
|
64
|
+
model: picked.id,
|
|
65
|
+
costPer1MInput: picked.costPer1MInput,
|
|
66
|
+
costPer1MOutput: picked.costPer1MOutput,
|
|
67
|
+
tier,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (ladder.length === 0)
|
|
71
|
+
throw new Error('The router found no tool-capable model. Pass --model.');
|
|
72
|
+
return ladder;
|
|
73
|
+
}
|
|
74
|
+
/** Dollar cost with cache reads and writes priced by their multipliers. */
|
|
75
|
+
export function computeCostUsd(choice, usage, cache) {
|
|
76
|
+
const read = Math.min(usage.cacheReadTokens, usage.inputTokens);
|
|
77
|
+
const write = Math.min(usage.cacheWriteTokens, usage.inputTokens - read);
|
|
78
|
+
const fresh = usage.inputTokens - read - write;
|
|
79
|
+
const inputUnits = fresh + read * cache.readMultiplier + write * cache.writeMultiplier;
|
|
80
|
+
return (inputUnits * choice.costPer1MInput + usage.outputTokens * choice.costPer1MOutput) / 1_000_000;
|
|
81
|
+
}
|
|
82
|
+
export const DEFAULT_RUNNER_CONFIG = {
|
|
83
|
+
escalateAfter: 2,
|
|
84
|
+
maxStepsPerAttempt: 40,
|
|
85
|
+
attemptTokenBudget: 200_000,
|
|
86
|
+
commandTimeoutMs: 120_000,
|
|
87
|
+
maxToolOutputChars: 8_000,
|
|
88
|
+
};
|
|
89
|
+
function envNumber(env, key) {
|
|
90
|
+
const raw = env[key];
|
|
91
|
+
if (raw === undefined || raw.trim() === '')
|
|
92
|
+
return undefined;
|
|
93
|
+
const n = Number(raw);
|
|
94
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
95
|
+
}
|
|
96
|
+
/** Precedence: explicit flag > PROFCLAW_RUN_* env var > default. */
|
|
97
|
+
export function resolveRunnerConfig(flags, env = process.env) {
|
|
98
|
+
const pick = (key, envKey) => flags[key] ?? envNumber(env, envKey) ?? DEFAULT_RUNNER_CONFIG[key];
|
|
99
|
+
const cfg = {
|
|
100
|
+
escalateAfter: Math.floor(pick('escalateAfter', 'PROFCLAW_RUN_ESCALATE_AFTER')),
|
|
101
|
+
maxStepsPerAttempt: Math.max(1, Math.floor(pick('maxStepsPerAttempt', 'PROFCLAW_RUN_MAX_STEPS'))),
|
|
102
|
+
attemptTokenBudget: Math.max(1, Math.floor(pick('attemptTokenBudget', 'PROFCLAW_RUN_ATTEMPT_TOKENS'))),
|
|
103
|
+
commandTimeoutMs: Math.max(1, pick('commandTimeoutMs', 'PROFCLAW_RUN_COMMAND_TIMEOUT_MS')),
|
|
104
|
+
maxToolOutputChars: Math.max(1, Math.floor(pick('maxToolOutputChars', 'PROFCLAW_RUN_TOOL_OUTPUT_CHARS'))),
|
|
105
|
+
};
|
|
106
|
+
return cfg;
|
|
107
|
+
}
|
|
108
|
+
const SYSTEM_PROMPT = [
|
|
109
|
+
'You are a coding agent working toward a goal inside an isolated git worktree.',
|
|
110
|
+
'Use the tools to read and edit files and to run commands. Every path is relative to the working directory and you cannot leave it.',
|
|
111
|
+
'Run the verify command yourself to check your work. The run only counts as done when that command exits 0.',
|
|
112
|
+
'Never push to a remote, never open pull requests, never touch git configuration.',
|
|
113
|
+
'When finished, call complete_task with a short summary.',
|
|
114
|
+
].join('\n');
|
|
115
|
+
export function buildAgentMessages(input) {
|
|
116
|
+
const lines = [
|
|
117
|
+
`Goal: ${input.goal}`,
|
|
118
|
+
`Verify command (must exit 0): ${input.verifyCommand}`,
|
|
119
|
+
`Attempt ${input.attempt}.`,
|
|
120
|
+
];
|
|
121
|
+
if (input.feedback) {
|
|
122
|
+
lines.push('', 'The previous attempt did not pass verification. Trimmed verifier output:', input.feedback);
|
|
123
|
+
}
|
|
124
|
+
return [
|
|
125
|
+
{ role: 'system', content: SYSTEM_PROMPT },
|
|
126
|
+
{ role: 'user', content: lines.join('\n') },
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
function stepUsage(result) {
|
|
130
|
+
return typeof result === 'object' && result !== null ? result['usage'] : undefined;
|
|
131
|
+
}
|
|
132
|
+
/** Tool results that report failure as data ({ success: false } or { error }) rather than throwing. */
|
|
133
|
+
function isFailedToolResult(out) {
|
|
134
|
+
if (typeof out !== 'object' || out === null)
|
|
135
|
+
return false;
|
|
136
|
+
const rec = out;
|
|
137
|
+
return rec['success'] === false || rec['ok'] === false || (typeof rec['error'] === 'string' && rec['error'] !== '');
|
|
138
|
+
}
|
|
139
|
+
export class ExecutorRunner {
|
|
140
|
+
options;
|
|
141
|
+
config;
|
|
142
|
+
env;
|
|
143
|
+
permissions;
|
|
144
|
+
tierIndex = 0;
|
|
145
|
+
failuresOnTier = 0;
|
|
146
|
+
spentTokens = 0;
|
|
147
|
+
spentUsd = 0;
|
|
148
|
+
constructor(options) {
|
|
149
|
+
this.options = options;
|
|
150
|
+
if (options.ladder.length === 0)
|
|
151
|
+
throw new Error('ExecutorRunner needs at least one model');
|
|
152
|
+
this.config = resolveRunnerConfig(options.config ?? {}, options.env);
|
|
153
|
+
this.env = options.env;
|
|
154
|
+
this.permissions = options.permissions ?? createRunPermissionManager();
|
|
155
|
+
}
|
|
156
|
+
/** Model the next attempt would use (for tests and diagnostics). */
|
|
157
|
+
get currentChoice() {
|
|
158
|
+
return this.options.ladder[this.tierIndex];
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* A call that arrives with feedback means the previous attempt failed
|
|
162
|
+
* verification (or crashed). After `escalateAfter` such failures in a row on
|
|
163
|
+
* the same model, move to the next tier up, if there is one.
|
|
164
|
+
*/
|
|
165
|
+
selectChoice(input) {
|
|
166
|
+
let escalated = false;
|
|
167
|
+
if (input.feedback !== undefined)
|
|
168
|
+
this.failuresOnTier++;
|
|
169
|
+
const { escalateAfter } = this.config;
|
|
170
|
+
if (escalateAfter > 0 &&
|
|
171
|
+
this.failuresOnTier >= escalateAfter &&
|
|
172
|
+
this.tierIndex < this.options.ladder.length - 1) {
|
|
173
|
+
this.tierIndex++;
|
|
174
|
+
this.failuresOnTier = 0;
|
|
175
|
+
escalated = true;
|
|
176
|
+
}
|
|
177
|
+
return { choice: this.currentChoice, escalated };
|
|
178
|
+
}
|
|
179
|
+
async runAttempt(input) {
|
|
180
|
+
const { choice, escalated } = this.selectChoice(input);
|
|
181
|
+
this.options.onEvent?.({ type: 'model_selected', attempt: input.attempt, choice, escalated });
|
|
182
|
+
const tokenCap = this.options.maxTokens ?? 0;
|
|
183
|
+
const costCap = this.options.maxCostUsd ?? 0;
|
|
184
|
+
const tokensLeft = tokenCap > 0 ? Math.max(1, tokenCap - this.spentTokens) : Number.POSITIVE_INFINITY;
|
|
185
|
+
const costLeft = costCap > 0 ? costCap - this.spentUsd : Number.POSITIVE_INFINITY;
|
|
186
|
+
const attemptBudget = Math.floor(Math.min(this.config.attemptTokenBudget, tokensLeft));
|
|
187
|
+
const toolset = createWorktreeToolset({
|
|
188
|
+
workdir: input.workdir,
|
|
189
|
+
permissions: this.permissions,
|
|
190
|
+
commandTimeoutMs: this.config.commandTimeoutMs,
|
|
191
|
+
maxOutputChars: this.config.maxToolOutputChars,
|
|
192
|
+
});
|
|
193
|
+
const sessionId = `verified-run-${randomUUID().slice(0, 8)}-a${input.attempt}`;
|
|
194
|
+
const loop = (this.options.createLoop ?? defaultCreateLoop)({
|
|
195
|
+
sessionId,
|
|
196
|
+
goal: input.goal,
|
|
197
|
+
config: {
|
|
198
|
+
maxSteps: this.config.maxStepsPerAttempt,
|
|
199
|
+
maxBudget: attemptBudget,
|
|
200
|
+
securityMode: 'sandbox',
|
|
201
|
+
// Tool timeout in the executor must outlast the shell timeout.
|
|
202
|
+
stepTimeoutMs: this.config.commandTimeoutMs + 5_000,
|
|
203
|
+
enableStreaming: false,
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
let cacheRead = 0;
|
|
207
|
+
let cacheWrite = 0;
|
|
208
|
+
let budgetStopped = false;
|
|
209
|
+
const usageNow = (state) => ({
|
|
210
|
+
inputTokens: state.inputTokensUsed,
|
|
211
|
+
outputTokens: state.outputTokensUsed,
|
|
212
|
+
cacheReadTokens: cacheRead,
|
|
213
|
+
cacheWriteTokens: cacheWrite,
|
|
214
|
+
});
|
|
215
|
+
loop.on('step:complete', (state, result) => {
|
|
216
|
+
const usage = extractCacheUsage(stepUsage(result));
|
|
217
|
+
cacheRead += usage.cacheReadTokens;
|
|
218
|
+
cacheWrite += usage.cacheWriteTokens;
|
|
219
|
+
if (computeCostUsd(choice, usageNow(state), getCacheConfigForModel(choice.model, this.env)) >= costLeft && !budgetStopped) {
|
|
220
|
+
budgetStopped = true;
|
|
221
|
+
loop.cancel();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
input.signal?.addEventListener('abort', () => loop.cancel(), { once: true });
|
|
225
|
+
let failure;
|
|
226
|
+
let finalState;
|
|
227
|
+
try {
|
|
228
|
+
const model = await this.options.createModel(choice);
|
|
229
|
+
const onToolCall = this.options.onToolCall;
|
|
230
|
+
const execute = onToolCall
|
|
231
|
+
? async (name, args) => {
|
|
232
|
+
const startedAt = Date.now();
|
|
233
|
+
try {
|
|
234
|
+
const out = await toolset.execute(name, args);
|
|
235
|
+
onToolCall({ attempt: input.attempt, name, args, durationMs: Date.now() - startedAt, ok: !isFailedToolResult(out) });
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
onToolCall({ attempt: input.attempt, name, args, durationMs: Date.now() - startedAt, ok: false });
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
: toolset.execute;
|
|
244
|
+
finalState = await loop.run(model, buildAgentMessages(input), toolset.tools, execute, choice.provider);
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
failure = error instanceof Error ? error.message : String(error);
|
|
248
|
+
finalState = loop.getState();
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
await toolset.dispose();
|
|
252
|
+
}
|
|
253
|
+
const inputTokens = finalState.inputTokensUsed;
|
|
254
|
+
const outputTokens = finalState.outputTokensUsed;
|
|
255
|
+
const tokens = inputTokens + outputTokens > 0 ? inputTokens + outputTokens : finalState.usedBudget;
|
|
256
|
+
const costUsd = computeCostUsd(choice, usageNow(finalState), getCacheConfigForModel(choice.model, this.env));
|
|
257
|
+
this.spentTokens += tokens;
|
|
258
|
+
this.spentUsd += costUsd;
|
|
259
|
+
// A failure before any tokens were spent is a setup problem (auth, model
|
|
260
|
+
// unavailable): surface it so the loop records an agent error. After real
|
|
261
|
+
// spend, report it in the summary and let the verifier judge the worktree.
|
|
262
|
+
if (failure !== undefined && tokens === 0)
|
|
263
|
+
throw new Error(failure);
|
|
264
|
+
const summary = this.summarize(finalState, failure, budgetStopped);
|
|
265
|
+
const cacheHitRate = inputTokens > 0 && cacheRead + cacheWrite > 0 ? cacheRead / inputTokens : undefined;
|
|
266
|
+
return {
|
|
267
|
+
summary,
|
|
268
|
+
tokensUsed: tokens,
|
|
269
|
+
costUsd,
|
|
270
|
+
model: choice.model,
|
|
271
|
+
cacheHitRate,
|
|
272
|
+
usage: { inputTokens, outputTokens, cacheReadTokens: cacheRead, cacheWriteTokens: cacheWrite },
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
summarize(state, failure, budgetStopped) {
|
|
276
|
+
if (budgetStopped)
|
|
277
|
+
return 'Stopped mid-attempt: the run cost budget was reached.';
|
|
278
|
+
if (failure !== undefined)
|
|
279
|
+
return `Agent error after partial work: ${failure}`;
|
|
280
|
+
return (state.finalResult?.summary ?? 'Agent finished without a summary.').slice(0, 500);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function defaultCreateLoop(args) {
|
|
284
|
+
return new AgentExecutor(args.sessionId, args.sessionId, args.goal, args.config);
|
|
285
|
+
}
|
|
286
|
+
/** Build a runner on the configured providers via the smart router. */
|
|
287
|
+
export async function createDefaultExecutorRunner(args) {
|
|
288
|
+
const { aiProvider } = await import('../providers/ai-sdk.js');
|
|
289
|
+
const env = args.env ?? process.env;
|
|
290
|
+
const modelSpec = args.model ?? (env['PROFCLAW_RUN_MODEL']?.trim() || undefined);
|
|
291
|
+
const ladder = buildModelLadder(args.goal, new Set(aiProvider.getConfiguredProviders()), modelSpec);
|
|
292
|
+
return new ExecutorRunner({
|
|
293
|
+
ladder,
|
|
294
|
+
createModel: (choice) => aiProvider.getModel(choice.provider, choice.model),
|
|
295
|
+
config: args.config,
|
|
296
|
+
env,
|
|
297
|
+
maxTokens: args.maxTokens,
|
|
298
|
+
maxCostUsd: args.maxCostUsd,
|
|
299
|
+
onEvent: args.onEvent,
|
|
300
|
+
onToolCall: args.onToolCall,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
//# sourceMappingURL=executor-runner.js.map
|
|
@@ -91,6 +91,8 @@ export declare class AgentExecutor extends EventEmitter<AgentEvents> {
|
|
|
91
91
|
*/
|
|
92
92
|
private injectFailureHint;
|
|
93
93
|
private extractContext;
|
|
94
|
+
/** Route one step's usage (with cache tokens) into the cost tracker. */
|
|
95
|
+
private trackStepUsage;
|
|
94
96
|
private finalize;
|
|
95
97
|
private collectArtifacts;
|
|
96
98
|
private collectNextSteps;
|
package/dist/agents/executor.js
CHANGED
|
@@ -13,6 +13,8 @@ import { EFFORT_BUDGET_MAP } from "./types.js";
|
|
|
13
13
|
import { defaultStopConditions, taskCompleted } from "./stop-conditions.js";
|
|
14
14
|
import { ToolCircuitBreaker } from "./circuit-breaker.js";
|
|
15
15
|
import { logger } from "../utils/logger.js";
|
|
16
|
+
import { applyAnthropicMessageCache, applyAnthropicToolCache, extractCacheUsage, } from "../providers/prompt-cache.js";
|
|
17
|
+
import { trackChatUsage } from "../costs/token-tracker.js";
|
|
16
18
|
import { ResultStore } from "./result-store.js";
|
|
17
19
|
import { getAgentSummaryTracker } from "./agent-summary.js";
|
|
18
20
|
import { getHookRegistry } from "../hooks/registry.js";
|
|
@@ -251,8 +253,8 @@ export class AgentExecutor extends EventEmitter {
|
|
|
251
253
|
const providerOptions = this.buildProviderOptions(providerHint);
|
|
252
254
|
const result = await generateText({
|
|
253
255
|
model,
|
|
254
|
-
messages,
|
|
255
|
-
tools: hasTools ? executableTools : undefined,
|
|
256
|
+
messages: applyAnthropicMessageCache(messages, providerHint ?? ''),
|
|
257
|
+
tools: hasTools ? applyAnthropicToolCache(executableTools, providerHint ?? '') : undefined,
|
|
256
258
|
...(providerOptions ? { providerOptions } : {}),
|
|
257
259
|
stopWhen: [
|
|
258
260
|
sdkStepCountIs(this.config.maxSteps),
|
|
@@ -270,6 +272,16 @@ export class AgentExecutor extends EventEmitter {
|
|
|
270
272
|
this.state.usedBudget += tokensUsed;
|
|
271
273
|
this.state.inputTokensUsed += stepInput;
|
|
272
274
|
this.state.outputTokensUsed += stepOutput;
|
|
275
|
+
const cacheUsage = extractCacheUsage(step.usage);
|
|
276
|
+
this.trackStepUsage(model, stepInput, stepOutput, cacheUsage);
|
|
277
|
+
if (cacheUsage.cacheReadTokens > 0 || cacheUsage.cacheWriteTokens > 0) {
|
|
278
|
+
logger.debug("[AgentExecutor] Prompt cache usage", {
|
|
279
|
+
sessionId: this.state.sessionId,
|
|
280
|
+
cacheReadTokens: cacheUsage.cacheReadTokens,
|
|
281
|
+
cacheWriteTokens: cacheUsage.cacheWriteTokens,
|
|
282
|
+
promptTokens: cacheUsage.promptTokens,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
273
285
|
// Update summary tracker with step progress
|
|
274
286
|
getAgentSummaryTracker().update(this.state.sessionId, {
|
|
275
287
|
status: 'thinking',
|
|
@@ -965,6 +977,27 @@ export class AgentExecutor extends EventEmitter {
|
|
|
965
977
|
}
|
|
966
978
|
}
|
|
967
979
|
}
|
|
980
|
+
/** Route one step's usage (with cache tokens) into the cost tracker. */
|
|
981
|
+
trackStepUsage(model, inputTokens, outputTokens, cache) {
|
|
982
|
+
const total = inputTokens + outputTokens;
|
|
983
|
+
if (total <= 0)
|
|
984
|
+
return;
|
|
985
|
+
const modelId = typeof model === "string" ? model : getRecord(model)?.modelId;
|
|
986
|
+
if (typeof modelId !== "string" || modelId.length === 0)
|
|
987
|
+
return;
|
|
988
|
+
try {
|
|
989
|
+
trackChatUsage(modelId, total, inputTokens, outputTokens, {
|
|
990
|
+
cacheReadTokens: cache.cacheReadTokens,
|
|
991
|
+
cacheWriteTokens: cache.cacheWriteTokens,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
catch (err) {
|
|
995
|
+
logger.warn("[AgentExecutor] Usage tracking failed", {
|
|
996
|
+
sessionId: this.state.sessionId,
|
|
997
|
+
error: err instanceof Error ? err.message : String(err),
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
968
1001
|
// Finalization
|
|
969
1002
|
finalize() {
|
|
970
1003
|
// Clean up any temp files written by the result store (fire-and-forget)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Output Truncator
|
|
3
|
+
*
|
|
4
|
+
* Caps large tool results (file reads, command output, logs, web content) to a
|
|
5
|
+
* configurable char budget before they reach the model. The head and the tail
|
|
6
|
+
* are kept (errors and summaries usually live at the end), the middle is
|
|
7
|
+
* replaced by an explicit marker, and the full result is kept in the
|
|
8
|
+
* ResultStore so the model can fetch a range with `fetch_result`.
|
|
9
|
+
*/
|
|
10
|
+
import type { ResultStore } from './result-store.js';
|
|
11
|
+
export interface TruncationConfig {
|
|
12
|
+
/** Max chars of a result sent to the model */
|
|
13
|
+
maxChars: number;
|
|
14
|
+
/** Fraction of the budget spent on the head (rest goes to the tail) */
|
|
15
|
+
headRatio: number;
|
|
16
|
+
/** Max chars returned by a single fetch_result call */
|
|
17
|
+
fetchMaxChars: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function getTruncationConfig(): TruncationConfig;
|
|
20
|
+
/** Truncation is on unless PROFCLAW_TOOL_OUTPUT_TRUNCATION is off/0/false/no. */
|
|
21
|
+
export declare function isTruncationEnabled(): boolean;
|
|
22
|
+
export interface TruncatedText {
|
|
23
|
+
text: string;
|
|
24
|
+
truncated: boolean;
|
|
25
|
+
/** Number of chars removed from the middle */
|
|
26
|
+
omitted: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Keep the head and tail of `text`, replacing the middle with a marker.
|
|
30
|
+
*/
|
|
31
|
+
export declare function truncateText(text: string, maxChars: number, headRatio?: number, hint?: string): TruncatedText;
|
|
32
|
+
export interface TruncatedResult {
|
|
33
|
+
/** Value to hand to the model (original reference when nothing was cut) */
|
|
34
|
+
value: unknown;
|
|
35
|
+
truncated: boolean;
|
|
36
|
+
omitted: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Truncate an arbitrary tool result. Long string fields are shortened in place
|
|
40
|
+
* so the object shape survives; if the whole payload is still over budget
|
|
41
|
+
* (e.g. a huge array), it falls back to truncating the JSON text.
|
|
42
|
+
*/
|
|
43
|
+
export declare function truncateResult(result: unknown, config?: TruncationConfig, hint?: string): TruncatedResult;
|
|
44
|
+
/**
|
|
45
|
+
* Truncate `result` if needed. When truncation happens the FULL result is
|
|
46
|
+
* saved in `store` under `toolCallId` so `fetch_result` can page through it.
|
|
47
|
+
*/
|
|
48
|
+
export declare function truncateAndStore(store: ResultStore, toolCallId: string, result: unknown, config?: TruncationConfig): Promise<TruncatedResult>;
|
|
49
|
+
export interface ResultRange {
|
|
50
|
+
resultId: string;
|
|
51
|
+
offset: number;
|
|
52
|
+
length: number;
|
|
53
|
+
totalChars: number;
|
|
54
|
+
content: string;
|
|
55
|
+
hasMore: boolean;
|
|
56
|
+
}
|
|
57
|
+
/** Read a char range of a stored full result (JSON text of the original). */
|
|
58
|
+
export declare function readStoredRange(store: ResultStore, resultId: string, offset: number, length: number, config?: TruncationConfig): Promise<ResultRange | null>;
|
|
59
|
+
//# sourceMappingURL=output-truncator.d.ts.map
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Output Truncator
|
|
3
|
+
*
|
|
4
|
+
* Caps large tool results (file reads, command output, logs, web content) to a
|
|
5
|
+
* configurable char budget before they reach the model. The head and the tail
|
|
6
|
+
* are kept (errors and summaries usually live at the end), the middle is
|
|
7
|
+
* replaced by an explicit marker, and the full result is kept in the
|
|
8
|
+
* ResultStore so the model can fetch a range with `fetch_result`.
|
|
9
|
+
*/
|
|
10
|
+
import { readFile } from 'node:fs/promises';
|
|
11
|
+
const DEFAULT_MAX_CHARS = 12_000;
|
|
12
|
+
const DEFAULT_HEAD_RATIO = 0.7;
|
|
13
|
+
function readPositiveInt(name, fallback) {
|
|
14
|
+
const raw = process.env[name];
|
|
15
|
+
if (!raw)
|
|
16
|
+
return fallback;
|
|
17
|
+
const parsed = Number.parseInt(raw, 10);
|
|
18
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
19
|
+
}
|
|
20
|
+
export function getTruncationConfig() {
|
|
21
|
+
const maxChars = readPositiveInt('PROFCLAW_TOOL_OUTPUT_MAX_CHARS', DEFAULT_MAX_CHARS);
|
|
22
|
+
const rawRatio = Number.parseFloat(process.env.PROFCLAW_TOOL_OUTPUT_HEAD_RATIO ?? '');
|
|
23
|
+
const headRatio = Number.isFinite(rawRatio) && rawRatio > 0 && rawRatio < 1 ? rawRatio : DEFAULT_HEAD_RATIO;
|
|
24
|
+
return {
|
|
25
|
+
maxChars,
|
|
26
|
+
headRatio,
|
|
27
|
+
fetchMaxChars: readPositiveInt('PROFCLAW_TOOL_FETCH_MAX_CHARS', maxChars),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** Truncation is on unless PROFCLAW_TOOL_OUTPUT_TRUNCATION is off/0/false/no. */
|
|
31
|
+
export function isTruncationEnabled() {
|
|
32
|
+
const flag = (process.env.PROFCLAW_TOOL_OUTPUT_TRUNCATION ?? '').toLowerCase();
|
|
33
|
+
return !['off', '0', 'false', 'no'].includes(flag);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Keep the head and tail of `text`, replacing the middle with a marker.
|
|
37
|
+
*/
|
|
38
|
+
export function truncateText(text, maxChars, headRatio = DEFAULT_HEAD_RATIO, hint = '') {
|
|
39
|
+
if (text.length <= maxChars) {
|
|
40
|
+
return { text, truncated: false, omitted: 0 };
|
|
41
|
+
}
|
|
42
|
+
const headLen = Math.floor(maxChars * headRatio);
|
|
43
|
+
const tailLen = Math.max(0, maxChars - headLen);
|
|
44
|
+
const omitted = text.length - headLen - tailLen;
|
|
45
|
+
const marker = `\n[truncated ${omitted} chars${hint ? `. ${hint}` : ''}]\n`;
|
|
46
|
+
const tail = tailLen > 0 ? text.slice(text.length - tailLen) : '';
|
|
47
|
+
return { text: text.slice(0, headLen) + marker + tail, truncated: true, omitted };
|
|
48
|
+
}
|
|
49
|
+
function shrinkStrings(value, maxChars, headRatio, hint, stats, depth) {
|
|
50
|
+
if (typeof value === 'string') {
|
|
51
|
+
const t = truncateText(value, maxChars, headRatio, hint);
|
|
52
|
+
stats.omitted += t.omitted;
|
|
53
|
+
return t.text;
|
|
54
|
+
}
|
|
55
|
+
if (depth > 6 || value === null || typeof value !== 'object')
|
|
56
|
+
return value;
|
|
57
|
+
if (Array.isArray(value)) {
|
|
58
|
+
return value.map((v) => shrinkStrings(v, maxChars, headRatio, hint, stats, depth + 1));
|
|
59
|
+
}
|
|
60
|
+
const out = {};
|
|
61
|
+
for (const [k, v] of Object.entries(value)) {
|
|
62
|
+
out[k] = shrinkStrings(v, maxChars, headRatio, hint, stats, depth + 1);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
function safeStringify(value) {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.stringify(value) ?? '';
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return String(value);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Truncate an arbitrary tool result. Long string fields are shortened in place
|
|
76
|
+
* so the object shape survives; if the whole payload is still over budget
|
|
77
|
+
* (e.g. a huge array), it falls back to truncating the JSON text.
|
|
78
|
+
*/
|
|
79
|
+
export function truncateResult(result, config = getTruncationConfig(), hint = '') {
|
|
80
|
+
const serialized = typeof result === 'string' ? result : safeStringify(result);
|
|
81
|
+
if (serialized.length <= config.maxChars) {
|
|
82
|
+
return { value: result, truncated: false, omitted: 0 };
|
|
83
|
+
}
|
|
84
|
+
if (typeof result === 'string') {
|
|
85
|
+
const t = truncateText(result, config.maxChars, config.headRatio, hint);
|
|
86
|
+
return { value: t.text, truncated: true, omitted: t.omitted };
|
|
87
|
+
}
|
|
88
|
+
const stats = { omitted: 0 };
|
|
89
|
+
const shrunk = shrinkStrings(result, config.maxChars, config.headRatio, hint, stats, 0);
|
|
90
|
+
const shrunkSerialized = safeStringify(shrunk);
|
|
91
|
+
if (shrunkSerialized.length <= config.maxChars * 1.25 + 256 && stats.omitted > 0) {
|
|
92
|
+
return { value: shrunk, truncated: true, omitted: stats.omitted };
|
|
93
|
+
}
|
|
94
|
+
const t = truncateText(serialized, config.maxChars, config.headRatio, hint);
|
|
95
|
+
return { value: t.text, truncated: true, omitted: t.omitted };
|
|
96
|
+
}
|
|
97
|
+
// Store integration
|
|
98
|
+
/**
|
|
99
|
+
* Truncate `result` if needed. When truncation happens the FULL result is
|
|
100
|
+
* saved in `store` under `toolCallId` so `fetch_result` can page through it.
|
|
101
|
+
*/
|
|
102
|
+
export async function truncateAndStore(store, toolCallId, result, config = getTruncationConfig()) {
|
|
103
|
+
const hint = `full output saved as result_id "${toolCallId}", call fetch_result with offset/length`;
|
|
104
|
+
const truncated = truncateResult(result, config, hint);
|
|
105
|
+
if (truncated.truncated) {
|
|
106
|
+
await store.store(toolCallId, result);
|
|
107
|
+
}
|
|
108
|
+
return truncated;
|
|
109
|
+
}
|
|
110
|
+
/** Read a char range of a stored full result (JSON text of the original). */
|
|
111
|
+
export async function readStoredRange(store, resultId, offset, length, config = getTruncationConfig()) {
|
|
112
|
+
const stored = store.retrieve(resultId);
|
|
113
|
+
if (!stored)
|
|
114
|
+
return null;
|
|
115
|
+
const full = stored.fullPath ? await readFile(stored.fullPath, 'utf8') : stored.inline;
|
|
116
|
+
const start = Math.max(0, Math.min(offset, full.length));
|
|
117
|
+
const len = Math.max(1, Math.min(length, config.fetchMaxChars));
|
|
118
|
+
const content = full.slice(start, start + len);
|
|
119
|
+
return {
|
|
120
|
+
resultId,
|
|
121
|
+
offset: start,
|
|
122
|
+
length: content.length,
|
|
123
|
+
totalChars: full.length,
|
|
124
|
+
content,
|
|
125
|
+
hasMore: start + content.length < full.length,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
//# sourceMappingURL=output-truncator.js.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers that connect receipts to the CLI: key handling and writer setup.
|
|
3
|
+
*/
|
|
4
|
+
import { ReceiptWriter } from './receipt-writer.js';
|
|
5
|
+
export interface RunReceiptArgs {
|
|
6
|
+
projectRoot: string;
|
|
7
|
+
runId: string;
|
|
8
|
+
/** --sign-key path; falls back to env PROFCLAW_RECEIPT_KEY */
|
|
9
|
+
signKeyPath?: string;
|
|
10
|
+
env?: NodeJS.ProcessEnv;
|
|
11
|
+
}
|
|
12
|
+
/** Create the writer for a run. Signing is off unless a key path is given. */
|
|
13
|
+
export declare function createRunReceipt(args: RunReceiptArgs): ReceiptWriter;
|
|
14
|
+
/** Version of the running profclaw package, or 'unknown'. */
|
|
15
|
+
export declare function toolVersion(): string;
|
|
16
|
+
export interface KeyPairFiles {
|
|
17
|
+
privateKeyPath: string;
|
|
18
|
+
publicKeyPath: string;
|
|
19
|
+
}
|
|
20
|
+
/** Write an Ed25519 keypair into dir. The private key file is mode 0600. */
|
|
21
|
+
export declare function generateKeyPairFiles(dir: string): KeyPairFiles;
|
|
22
|
+
/** Short one-line description of tool arguments. Long values are cut so files are not copied into the receipt. */
|
|
23
|
+
export declare function summarizeArgs(args: Record<string, unknown>, maxChars?: number): string;
|
|
24
|
+
export interface RunReceiptSetup {
|
|
25
|
+
runId: string;
|
|
26
|
+
receipt?: ReceiptWriter;
|
|
27
|
+
/** Hook for ExecutorRunner: records each tool call */
|
|
28
|
+
onToolCall?: (event: {
|
|
29
|
+
attempt: number;
|
|
30
|
+
name: string;
|
|
31
|
+
args: Record<string, unknown>;
|
|
32
|
+
durationMs: number;
|
|
33
|
+
ok: boolean;
|
|
34
|
+
}) => void;
|
|
35
|
+
}
|
|
36
|
+
/** Everything `profclaw run` needs to record a receipt; a no-op setup when disabled. */
|
|
37
|
+
export declare function setupRunReceipt(args: {
|
|
38
|
+
enabled: boolean;
|
|
39
|
+
projectRoot: string;
|
|
40
|
+
signKeyPath?: string;
|
|
41
|
+
env?: NodeJS.ProcessEnv;
|
|
42
|
+
}): RunReceiptSetup;
|
|
43
|
+
//# sourceMappingURL=receipt-cli.d.ts.map
|