@mafdet/cli 0.3.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.
Files changed (107) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/LICENSE +21 -0
  3. package/README.md +173 -0
  4. package/README.zh-CN.md +148 -0
  5. package/dist/agent/approvals.d.ts +38 -0
  6. package/dist/agent/approvals.d.ts.map +1 -0
  7. package/dist/agent/approvals.js +53 -0
  8. package/dist/agent/command-policy.d.ts +36 -0
  9. package/dist/agent/command-policy.d.ts.map +1 -0
  10. package/dist/agent/command-policy.js +133 -0
  11. package/dist/agent/loop.d.ts +107 -0
  12. package/dist/agent/loop.d.ts.map +1 -0
  13. package/dist/agent/loop.js +242 -0
  14. package/dist/agent/session.d.ts +62 -0
  15. package/dist/agent/session.d.ts.map +1 -0
  16. package/dist/agent/session.js +290 -0
  17. package/dist/agent/tools/read-only.d.ts +15 -0
  18. package/dist/agent/tools/read-only.d.ts.map +1 -0
  19. package/dist/agent/tools/read-only.js +254 -0
  20. package/dist/agent/tools/run-command.d.ts +20 -0
  21. package/dist/agent/tools/run-command.d.ts.map +1 -0
  22. package/dist/agent/tools/run-command.js +116 -0
  23. package/dist/agent/tools/types.d.ts +46 -0
  24. package/dist/agent/tools/types.d.ts.map +1 -0
  25. package/dist/agent/tools/types.js +16 -0
  26. package/dist/agent/tools/write.d.ts +3 -0
  27. package/dist/agent/tools/write.d.ts.map +1 -0
  28. package/dist/agent/tools/write.js +68 -0
  29. package/dist/api/account.d.ts +39 -0
  30. package/dist/api/account.d.ts.map +1 -0
  31. package/dist/api/account.js +39 -0
  32. package/dist/api/errors.d.ts +38 -0
  33. package/dist/api/errors.d.ts.map +1 -0
  34. package/dist/api/errors.js +149 -0
  35. package/dist/api/http.d.ts +63 -0
  36. package/dist/api/http.d.ts.map +1 -0
  37. package/dist/api/http.js +207 -0
  38. package/dist/api/models.d.ts +68 -0
  39. package/dist/api/models.d.ts.map +1 -0
  40. package/dist/api/models.js +91 -0
  41. package/dist/api/sse-parser.d.ts +72 -0
  42. package/dist/api/sse-parser.d.ts.map +1 -0
  43. package/dist/api/sse-parser.js +163 -0
  44. package/dist/commands/account.d.ts +4 -0
  45. package/dist/commands/account.d.ts.map +1 -0
  46. package/dist/commands/account.js +103 -0
  47. package/dist/commands/agent.d.ts +10 -0
  48. package/dist/commands/agent.d.ts.map +1 -0
  49. package/dist/commands/agent.js +268 -0
  50. package/dist/commands/chat.d.ts +13 -0
  51. package/dist/commands/chat.d.ts.map +1 -0
  52. package/dist/commands/chat.js +191 -0
  53. package/dist/commands/config.d.ts +15 -0
  54. package/dist/commands/config.d.ts.map +1 -0
  55. package/dist/commands/config.js +74 -0
  56. package/dist/commands/doctor.d.ts +15 -0
  57. package/dist/commands/doctor.d.ts.map +1 -0
  58. package/dist/commands/doctor.js +157 -0
  59. package/dist/commands/embed.d.ts +7 -0
  60. package/dist/commands/embed.d.ts.map +1 -0
  61. package/dist/commands/embed.js +105 -0
  62. package/dist/commands/models.d.ts +4 -0
  63. package/dist/commands/models.d.ts.map +1 -0
  64. package/dist/commands/models.js +151 -0
  65. package/dist/commands/runtime.d.ts +29 -0
  66. package/dist/commands/runtime.d.ts.map +1 -0
  67. package/dist/commands/runtime.js +56 -0
  68. package/dist/commands/speech.d.ts +7 -0
  69. package/dist/commands/speech.d.ts.map +1 -0
  70. package/dist/commands/speech.js +111 -0
  71. package/dist/config/config.d.ts +57 -0
  72. package/dist/config/config.d.ts.map +1 -0
  73. package/dist/config/config.js +129 -0
  74. package/dist/config/load.d.ts +20 -0
  75. package/dist/config/load.d.ts.map +1 -0
  76. package/dist/config/load.js +48 -0
  77. package/dist/config/paths.d.ts +11 -0
  78. package/dist/config/paths.d.ts.map +1 -0
  79. package/dist/config/paths.js +26 -0
  80. package/dist/filesystem/diff.d.ts +12 -0
  81. package/dist/filesystem/diff.d.ts.map +1 -0
  82. package/dist/filesystem/diff.js +109 -0
  83. package/dist/filesystem/sensitive-files.d.ts +19 -0
  84. package/dist/filesystem/sensitive-files.d.ts.map +1 -0
  85. package/dist/filesystem/sensitive-files.js +46 -0
  86. package/dist/filesystem/workspace.d.ts +92 -0
  87. package/dist/filesystem/workspace.d.ts.map +1 -0
  88. package/dist/filesystem/workspace.js +264 -0
  89. package/dist/index.d.ts +4 -0
  90. package/dist/index.d.ts.map +1 -0
  91. package/dist/index.js +109 -0
  92. package/dist/output/exit-codes.d.ts +25 -0
  93. package/dist/output/exit-codes.d.ts.map +1 -0
  94. package/dist/output/exit-codes.js +23 -0
  95. package/dist/output/format.d.ts +28 -0
  96. package/dist/output/format.d.ts.map +1 -0
  97. package/dist/output/format.js +56 -0
  98. package/dist/output/money.d.ts +16 -0
  99. package/dist/output/money.d.ts.map +1 -0
  100. package/dist/output/money.js +35 -0
  101. package/dist/output/write-file.d.ts +11 -0
  102. package/dist/output/write-file.d.ts.map +1 -0
  103. package/dist/output/write-file.js +38 -0
  104. package/dist/security/redact.d.ts +16 -0
  105. package/dist/security/redact.d.ts.map +1 -0
  106. package/dist/security/redact.js +61 -0
  107. package/package.json +51 -0
@@ -0,0 +1,133 @@
1
+ /**
2
+ * What a model is allowed to ask the machine to run.
3
+ *
4
+ * The central decision: there is no shell. A command is tokenised into argv
5
+ * and handed to execFile, so `;`, `&&`, backticks, `$(...)`, pipes and
6
+ * redirects have no meaning to anything downstream — there is nothing to
7
+ * inject into. Rather than accept them and try to sanitise, the policy refuses
8
+ * them and says why, because a sanitiser is a thing that can be wrong once.
9
+ */
10
+ /** Shell syntax that would only make sense if a shell were involved. */
11
+ const SHELL_METACHARACTERS = /[;&|`$><(){}\n\r]|\$\(|\|\|/;
12
+ /** Refused outright, even with an approval and even on the allowlist. */
13
+ const NEVER = [
14
+ { pattern: /^sudo$/i, reason: "sudo is never run by the agent" },
15
+ { pattern: /^su$/i, reason: "su is never run by the agent" },
16
+ { pattern: /^doas$/i, reason: "doas is never run by the agent" },
17
+ { pattern: /^chown$/i, reason: "changing ownership is out of scope" },
18
+ { pattern: /^shutdown$/i, reason: "power commands are out of scope" },
19
+ { pattern: /^reboot$/i, reason: "power commands are out of scope" },
20
+ ];
21
+ /**
22
+ * Split a command line the way a shell would for simple cases: whitespace
23
+ * separates, quotes group. Anything more exotic is refused above, so this
24
+ * never has to be clever.
25
+ */
26
+ export function tokenize(command) {
27
+ const tokens = [];
28
+ let current = "";
29
+ let quote;
30
+ let started = false;
31
+ for (const char of command) {
32
+ if (quote) {
33
+ if (char === quote)
34
+ quote = undefined;
35
+ else
36
+ current += char;
37
+ continue;
38
+ }
39
+ if (char === '"' || char === "'") {
40
+ quote = char;
41
+ started = true;
42
+ continue;
43
+ }
44
+ if (/\s/.test(char)) {
45
+ if (started || current.length > 0)
46
+ tokens.push(current);
47
+ current = "";
48
+ started = false;
49
+ continue;
50
+ }
51
+ current += char;
52
+ }
53
+ if (quote)
54
+ throw new Error("unbalanced quote");
55
+ if (started || current.length > 0)
56
+ tokens.push(current);
57
+ return tokens;
58
+ }
59
+ /**
60
+ * @param allowlist entries from `--allow-command`. An entry matches when it is
61
+ * the whole command or a leading prefix of its tokens, so "pnpm test"
62
+ * permits "pnpm test --filter x" but never "pnpm publish".
63
+ */
64
+ export function assessCommand(command, allowlist = []) {
65
+ const trimmed = command.trim();
66
+ if (trimmed.length === 0)
67
+ return { ok: false, reason: "empty command" };
68
+ if (trimmed.includes("\0"))
69
+ return { ok: false, reason: "command contains a NUL byte" };
70
+ if (SHELL_METACHARACTERS.test(trimmed)) {
71
+ return {
72
+ ok: false,
73
+ reason: "shell syntax is not supported - commands run directly, without a shell, so pipes, redirects, chaining and substitution cannot be used. Run one program at a time.",
74
+ };
75
+ }
76
+ let argv;
77
+ try {
78
+ argv = tokenize(trimmed);
79
+ }
80
+ catch (error) {
81
+ return { ok: false, reason: error.message };
82
+ }
83
+ if (argv.length === 0)
84
+ return { ok: false, reason: "empty command" };
85
+ const program = argv[0];
86
+ if (program.startsWith("-")) {
87
+ return { ok: false, reason: "a command must start with a program name" };
88
+ }
89
+ // An absolute or relative path as the program would sidestep both the
90
+ // allowlist's intent and PATH; the workspace boundary does not cover exec.
91
+ if (program.includes("/") || program.includes("\\")) {
92
+ return {
93
+ ok: false,
94
+ reason: "run a program by name, not by path",
95
+ };
96
+ }
97
+ for (const rule of NEVER) {
98
+ if (rule.pattern.test(program))
99
+ return { ok: false, reason: rule.reason };
100
+ }
101
+ return { ok: true, argv, preApproved: matchesAllowlist(argv, allowlist) };
102
+ }
103
+ function matchesAllowlist(argv, allowlist) {
104
+ for (const entry of allowlist) {
105
+ let prefix;
106
+ try {
107
+ prefix = tokenize(entry.trim());
108
+ }
109
+ catch {
110
+ continue;
111
+ }
112
+ if (prefix.length === 0 || prefix.length > argv.length)
113
+ continue;
114
+ if (prefix.every((token, index) => token === argv[index]))
115
+ return true;
116
+ }
117
+ return false;
118
+ }
119
+ /** Terminal control sequences, stripped before any output is shown or sent. */
120
+ export function stripAnsi(text) {
121
+ return (text
122
+ // CSI: ESC [ ... final byte - colours, cursor moves, screen clears.
123
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "")
124
+ // OSC: ESC ] ... BEL (or ST). The payload has to go too - stripping only
125
+ // the introducer would leave "0;retitled" sitting in the output.
126
+ .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g, "")
127
+ // Other single-character escapes, including OSC introducers.
128
+ .replace(/\u001b[@-Z\\-_]/g, "")
129
+ // Remaining C0 controls. Tab, newline and carriage return survive; the
130
+ // rest can retitle a window, move the cursor or hide what follows, and a
131
+ // model's output is not a trusted source of terminal instructions.
132
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, ""));
133
+ }
@@ -0,0 +1,107 @@
1
+ import type { HttpClient } from "../api/http.js";
2
+ import type { CatalogModel } from "../api/models.js";
3
+ import type { Approver } from "./approvals.js";
4
+ import { type ToolContext, type ToolDefinition } from "./tools/types.js";
5
+ /** Plan §7.4. */
6
+ export declare const DEFAULT_LIMITS: {
7
+ maxTurns: number;
8
+ maxWallClockMs: number;
9
+ /** Bounded 429 handling (§7.5): both the count and the total wait are capped. */
10
+ maxRateLimitRetries: number;
11
+ maxTotalWaitMs: number;
12
+ maxCostMicroUsd: bigint | undefined;
13
+ };
14
+ export type AgentLimits = typeof DEFAULT_LIMITS;
15
+ export type StopReason = "completed" | "max_turns" | "timeout" | "max_cost" | "cancelled" | "rate_limited" | "error";
16
+ export interface AgentReport {
17
+ stopReason: StopReason;
18
+ /** The model's last text answer, if it produced one. */
19
+ finalText: string;
20
+ turns: number;
21
+ modelRequests: number;
22
+ rateLimitHits: number;
23
+ totalWaitMs: number;
24
+ toolRuns: {
25
+ name: string;
26
+ ok: boolean;
27
+ }[];
28
+ promptTokens: number;
29
+ completionTokens: number;
30
+ costMicroUsd: bigint;
31
+ /** Set when the run ended because something failed. */
32
+ error?: string;
33
+ }
34
+ export interface AgentOptions {
35
+ client: HttpClient;
36
+ model: string;
37
+ /** Priced from the catalogue with the gateway's own arithmetic. */
38
+ pricing?: CatalogModel;
39
+ goal: string;
40
+ systemPrompt: string;
41
+ tools: ToolDefinition[];
42
+ toolContext: ToolContext;
43
+ /**
44
+ * Conversation carried across goals by an interactive session. Passed in, it
45
+ * is appended to in place, so the caller keeps the state and can compact it.
46
+ */
47
+ history?: ChatMessage[];
48
+ /** Asked before any tool that is not auto-approved. Defaults to refusing. */
49
+ approver?: Approver;
50
+ limits?: Partial<AgentLimits>;
51
+ signal?: AbortSignal;
52
+ /** Progress goes to stderr; the caller decides how to show it. */
53
+ onEvent?: (event: AgentEvent) => void;
54
+ /** Injected in tests to make backoff deterministic. */
55
+ sleep?: (ms: number) => Promise<void>;
56
+ now?: () => number;
57
+ random?: () => number;
58
+ }
59
+ export type AgentEvent = {
60
+ type: "turn";
61
+ turn: number;
62
+ } | {
63
+ type: "tool";
64
+ name: string;
65
+ ok: boolean;
66
+ detail: string;
67
+ } | {
68
+ type: "declined";
69
+ name: string;
70
+ summary: string;
71
+ } | {
72
+ type: "text";
73
+ text: string;
74
+ } | {
75
+ type: "rate_limited";
76
+ waitMs: number;
77
+ attempt: number;
78
+ };
79
+ export interface ChatMessage {
80
+ role: "system" | "user" | "assistant" | "tool";
81
+ content: string | null;
82
+ tool_calls?: ToolCall[];
83
+ tool_call_id?: string;
84
+ }
85
+ interface ToolCall {
86
+ id: string;
87
+ type: "function";
88
+ function: {
89
+ name: string;
90
+ arguments: string;
91
+ };
92
+ }
93
+ /**
94
+ * The read-only agent loop.
95
+ *
96
+ * Two rules shape everything here. Model requests are strictly serial, so the
97
+ * key's RPM is never the thing that breaks a run. And a request that may
98
+ * already have been billed is never replayed: only an explicit 429 is
99
+ * retried, because CLI-001 measured that a throttled request writes a
100
+ * zero-charge ledger row, while a timeout or a 5xx may mean the model already
101
+ * generated an answer someone paid for.
102
+ */
103
+ export declare function runAgent(options: AgentOptions): Promise<AgentReport>;
104
+ /** Retry-After when the server gave one; otherwise exponential with jitter. */
105
+ export declare function backoffMs(retryAfterSeconds: number | undefined, attempt: number, random?: () => number): number;
106
+ export {};
107
+ //# sourceMappingURL=loop.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,242 @@
1
+ import { ApiError } from "../api/errors.js";
2
+ import { toolToJsonSchema, } from "./tools/types.js";
3
+ /** Plan §7.4. */
4
+ export const DEFAULT_LIMITS = {
5
+ maxTurns: 30,
6
+ maxWallClockMs: 30 * 60 * 1000,
7
+ /** Bounded 429 handling (§7.5): both the count and the total wait are capped. */
8
+ maxRateLimitRetries: 5,
9
+ maxTotalWaitMs: 120_000,
10
+ maxCostMicroUsd: undefined,
11
+ };
12
+ /**
13
+ * The read-only agent loop.
14
+ *
15
+ * Two rules shape everything here. Model requests are strictly serial, so the
16
+ * key's RPM is never the thing that breaks a run. And a request that may
17
+ * already have been billed is never replayed: only an explicit 429 is
18
+ * retried, because CLI-001 measured that a throttled request writes a
19
+ * zero-charge ledger row, while a timeout or a 5xx may mean the model already
20
+ * generated an answer someone paid for.
21
+ */
22
+ export async function runAgent(options) {
23
+ const limits = { ...DEFAULT_LIMITS, ...options.limits };
24
+ const now = options.now ?? Date.now;
25
+ const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
26
+ const random = options.random ?? Math.random;
27
+ const started = now();
28
+ const byName = new Map(options.tools.map((tool) => [tool.name, tool]));
29
+ const toolSchemas = options.tools.map(toolToJsonSchema);
30
+ const messages = options.history ?? [];
31
+ if (messages.length === 0) {
32
+ messages.push({ role: "system", content: options.systemPrompt });
33
+ }
34
+ messages.push({ role: "user", content: options.goal });
35
+ const report = {
36
+ stopReason: "completed",
37
+ finalText: "",
38
+ turns: 0,
39
+ modelRequests: 0,
40
+ rateLimitHits: 0,
41
+ totalWaitMs: 0,
42
+ toolRuns: [],
43
+ promptTokens: 0,
44
+ completionTokens: 0,
45
+ costMicroUsd: 0n,
46
+ };
47
+ for (let turn = 1; turn <= limits.maxTurns; turn += 1) {
48
+ if (options.signal?.aborted)
49
+ return stop(report, "cancelled");
50
+ if (now() - started > limits.maxWallClockMs)
51
+ return stop(report, "timeout");
52
+ if (limits.maxCostMicroUsd !== undefined &&
53
+ report.costMicroUsd >= limits.maxCostMicroUsd) {
54
+ return stop(report, "max_cost");
55
+ }
56
+ report.turns = turn;
57
+ options.onEvent?.({ type: "turn", turn });
58
+ let response;
59
+ try {
60
+ response = await requestWithRateLimitHandling(options, limits, {
61
+ model: options.model,
62
+ messages,
63
+ tools: toolSchemas,
64
+ tool_choice: "auto",
65
+ }, report, { sleep, now, random, started });
66
+ }
67
+ catch (error) {
68
+ if (error instanceof ApiError && error.kind === "cancelled") {
69
+ return stop(report, "cancelled");
70
+ }
71
+ if (error instanceof ApiError && error.kind === "rate_limited") {
72
+ return stop(report, "rate_limited", error.message);
73
+ }
74
+ return stop(report, "error", error instanceof Error ? error.message : String(error));
75
+ }
76
+ const usage = response.usage;
77
+ report.promptTokens += usage?.prompt_tokens ?? 0;
78
+ report.completionTokens += usage?.completion_tokens ?? 0;
79
+ // Cost comes from returned usage, never from the reservation: CLI-000B
80
+ // showed the reserve cannot see the tool schema and under-counts badly.
81
+ report.costMicroUsd += priceTurn(options.pricing, usage);
82
+ const choice = response.choices?.[0];
83
+ const message = choice?.message;
84
+ const calls = message?.tool_calls ?? [];
85
+ if (typeof message?.content === "string" && message.content.length > 0) {
86
+ report.finalText = message.content;
87
+ options.onEvent?.({ type: "text", text: message.content });
88
+ }
89
+ if (calls.length === 0) {
90
+ // Keep the answer in the history so a session's next turn has context.
91
+ messages.push({ role: "assistant", content: message?.content ?? "" });
92
+ return stop(report, "completed");
93
+ }
94
+ messages.push({
95
+ role: "assistant",
96
+ content: message?.content ?? null,
97
+ tool_calls: calls,
98
+ });
99
+ // Serial on purpose: two tools must never race on the same file, and a
100
+ // cancel between them must stop the rest.
101
+ for (const call of calls) {
102
+ if (options.signal?.aborted)
103
+ return stop(report, "cancelled");
104
+ const { text, ok } = await runToolCall(call, byName, options);
105
+ report.toolRuns.push({ name: call.function.name, ok });
106
+ options.onEvent?.({
107
+ type: "tool",
108
+ name: call.function.name,
109
+ ok,
110
+ detail: text,
111
+ });
112
+ messages.push({ role: "tool", tool_call_id: call.id, content: text });
113
+ }
114
+ }
115
+ return stop(report, "max_turns");
116
+ }
117
+ /**
118
+ * A tool failure is data for the model, not the end of the run: a bad path or
119
+ * a stale hash should come back as a structured message it can correct from
120
+ * (§7.2). What the model may never do is reach a tool that is not on the list.
121
+ */
122
+ async function runToolCall(call, byName, options) {
123
+ const context = options.toolContext;
124
+ const tool = byName.get(call.function.name);
125
+ if (!tool) {
126
+ return {
127
+ ok: false,
128
+ text: `error: no such tool '${call.function.name}'. Available: ${[...byName.keys()].join(", ")}`,
129
+ };
130
+ }
131
+ let args;
132
+ try {
133
+ args = JSON.parse(call.function.arguments || "{}");
134
+ }
135
+ catch {
136
+ return { ok: false, text: "error: arguments were not valid JSON" };
137
+ }
138
+ const parsed = tool.schema.safeParse(args);
139
+ if (!parsed.success) {
140
+ return {
141
+ ok: false,
142
+ text: `error: invalid arguments: ${parsed.error.issues
143
+ .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
144
+ .join("; ")}`,
145
+ };
146
+ }
147
+ try {
148
+ if (tool.autoApproved) {
149
+ if (!tool.run)
150
+ throw new Error(`${tool.name} has no implementation`);
151
+ return { ok: true, text: tool.run(parsed.data, context) };
152
+ }
153
+ if (!tool.prepare)
154
+ throw new Error(`${tool.name} cannot be approved`);
155
+ // Prepare first: this computes the diff and validates the edit without
156
+ // touching the disk, so a refusal below leaves nothing behind.
157
+ const action = tool.prepare(parsed.data, context);
158
+ // No approver means no writes. Failing closed here is what stops a
159
+ // miswired caller from turning a read-only run into a writing one.
160
+ // An action can carry advance approval - an --allow-command entry the user
161
+ // declared up front - which is the only way anything runs unprompted.
162
+ const approver = options.approver;
163
+ const approved = action.preApproved === true ||
164
+ (approver
165
+ ? await approver.approve({
166
+ summary: action.summary,
167
+ preview: action.preview,
168
+ })
169
+ : false);
170
+ if (!approved) {
171
+ options.onEvent?.({
172
+ type: "declined",
173
+ name: tool.name,
174
+ summary: action.summary,
175
+ });
176
+ return {
177
+ ok: false,
178
+ text: `the user declined: ${action.summary}. Nothing was changed or run. Do not retry the same action; ask what to do differently.`,
179
+ };
180
+ }
181
+ return { ok: true, text: await action.commit() };
182
+ }
183
+ catch (error) {
184
+ return { ok: false, text: `error: ${error.message}` };
185
+ }
186
+ }
187
+ async function requestWithRateLimitHandling(options, limits, body, report, clock) {
188
+ for (let attempt = 0;; attempt += 1) {
189
+ try {
190
+ report.modelRequests += 1;
191
+ const { data } = await options.client.postJson("/chat/completions", body, { signal: options.signal });
192
+ return data;
193
+ }
194
+ catch (error) {
195
+ // Everything except an explicit 429 propagates. A timeout or a 5xx may
196
+ // already have produced (and billed) a generation, so replaying it
197
+ // would risk paying twice for one answer.
198
+ if (!(error instanceof ApiError) || !error.retryable)
199
+ throw error;
200
+ report.rateLimitHits += 1;
201
+ if (attempt + 1 > limits.maxRateLimitRetries)
202
+ throw error;
203
+ const wait = backoffMs(error.retryAfterSeconds, attempt, clock.random);
204
+ if (report.totalWaitMs + wait > limits.maxTotalWaitMs)
205
+ throw error;
206
+ if (clock.now() - clock.started + wait > limits.maxWallClockMs)
207
+ throw error;
208
+ report.totalWaitMs += wait;
209
+ options.onEvent?.({
210
+ type: "rate_limited",
211
+ waitMs: wait,
212
+ attempt: attempt + 1,
213
+ });
214
+ await clock.sleep(wait);
215
+ }
216
+ }
217
+ }
218
+ /** Retry-After when the server gave one; otherwise exponential with jitter. */
219
+ export function backoffMs(retryAfterSeconds, attempt, random = Math.random) {
220
+ if (retryAfterSeconds !== undefined && retryAfterSeconds >= 0) {
221
+ return Math.min(retryAfterSeconds * 1000, 60_000);
222
+ }
223
+ const base = Math.min(1000 * 2 ** attempt, 30_000);
224
+ // Full jitter: a fleet of CLIs must not retry in lockstep.
225
+ return Math.round(base * (0.5 + random() * 0.5));
226
+ }
227
+ function priceTurn(model, usage) {
228
+ if (!model?.inputPricePer1mMicroUsd ||
229
+ !model.outputPricePer1mMicroUsd ||
230
+ !usage) {
231
+ return 0n;
232
+ }
233
+ const ceilDiv = (tokens, price) => (BigInt(Math.max(0, tokens)) * BigInt(price) + 999999n) / 1000000n;
234
+ return (ceilDiv(usage.prompt_tokens ?? 0, model.inputPricePer1mMicroUsd) +
235
+ ceilDiv(usage.completion_tokens ?? 0, model.outputPricePer1mMicroUsd));
236
+ }
237
+ function stop(report, reason, error) {
238
+ report.stopReason = reason;
239
+ if (error)
240
+ report.error = error;
241
+ return report;
242
+ }
@@ -0,0 +1,62 @@
1
+ import type { HttpClient } from "../api/http.js";
2
+ import type { CatalogModel } from "../api/models.js";
3
+ import type { Approver } from "./approvals.js";
4
+ import { type ChatMessage } from "./loop.js";
5
+ import type { ToolContext, ToolDefinition } from "./tools/types.js";
6
+ export interface SessionOptions {
7
+ client: HttpClient;
8
+ model: string;
9
+ pricing?: CatalogModel;
10
+ /** Everything the catalogue says this key may drive, for /model. */
11
+ catalog: readonly CatalogModel[];
12
+ systemPrompt: string;
13
+ tools: ToolDefinition[];
14
+ toolContext: ToolContext;
15
+ approver?: Approver;
16
+ mode: string;
17
+ allowedCommands: readonly string[];
18
+ stdout: (text: string) => void;
19
+ stderr: (text: string) => void;
20
+ input?: NodeJS.ReadableStream;
21
+ /**
22
+ * Reads the next line, or undefined at end of input. Injected by tests;
23
+ * production wraps readline. Keeping this a function rather than a stream
24
+ * means the session's control flow can be tested without a terminal.
25
+ */
26
+ readLine?: () => Promise<string | undefined>;
27
+ signal?: AbortSignal;
28
+ }
29
+ export interface SessionSummary {
30
+ goals: number;
31
+ turns: number;
32
+ modelRequests: number;
33
+ toolRuns: number;
34
+ promptTokens: number;
35
+ completionTokens: number;
36
+ costMicroUsd: bigint;
37
+ }
38
+ /**
39
+ * The interactive session (plan §4.4).
40
+ *
41
+ * One conversation across many goals, so the agent keeps what it learned. The
42
+ * session owns the history — which is what makes /compact possible — and the
43
+ * running totals, so /cost and the closing summary describe the whole session
44
+ * rather than the last question.
45
+ */
46
+ export declare function runSession(options: SessionOptions): Promise<SessionSummary>;
47
+ /**
48
+ * Shorten the conversation in place.
49
+ *
50
+ * This truncates rather than summarising: a summary would cost a model call
51
+ * and could quietly invent detail. It keeps the system prompt and the opening
52
+ * goal, then cuts to the most recent user message.
53
+ *
54
+ * The cut must land on a user message. An assistant turn that carries
55
+ * `tool_calls` and its matching `tool` results form one indivisible unit — cut
56
+ * between them and the next request is rejected for referencing a tool call
57
+ * that is no longer there.
58
+ *
59
+ * @returns how many messages were removed.
60
+ */
61
+ export declare function compactHistory(history: ChatMessage[]): number;
62
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/agent/session.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAIrD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,EAA6B,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AACxE,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAapE,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,oEAAoE;IACpE,OAAO,EAAE,SAAS,YAAY,EAAE,CAAC;IACjC,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,cAAc,EAAE,CAAC;IACxB,WAAW,EAAE,WAAW,CAAC;IACzB,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC,cAAc,CAAC;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAC7C,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;GAOG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,cAAc,CAAC,CAwFzB;AAgHD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,WAAW,EAAE,GAAG,MAAM,CA2B7D"}