pi-sage 0.2.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/.pi/extensions/sage/index.ts +659 -0
- package/.pi/extensions/sage/policy.ts +114 -0
- package/.pi/extensions/sage/runner.ts +461 -0
- package/.pi/extensions/sage/settings.ts +202 -0
- package/.pi/extensions/sage/tool-policy.ts +195 -0
- package/.pi/extensions/sage/types.ts +108 -0
- package/AGENTS.md +87 -0
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/docs/SAGE_SPEC.md +490 -0
- package/docs/coding-standards.md +116 -0
- package/docs/installation-requirements.md +70 -0
- package/docs/interactive-e2e-harness.md +46 -0
- package/docs/testing-standards.md +175 -0
- package/package.json +62 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import {
|
|
4
|
+
evaluateSoftBudget,
|
|
5
|
+
isEligibleCaller,
|
|
6
|
+
isHardCostCapExceeded,
|
|
7
|
+
makeBlockedResult
|
|
8
|
+
} from "./policy.js";
|
|
9
|
+
import { isSageRunnerPolicyError, runSageSingleShot } from "./runner.js";
|
|
10
|
+
import {
|
|
11
|
+
getSettingsPathForScope,
|
|
12
|
+
loadSettings,
|
|
13
|
+
mergeSettings,
|
|
14
|
+
saveSettings,
|
|
15
|
+
type SageSettings,
|
|
16
|
+
type SettingsScope
|
|
17
|
+
} from "./settings.js";
|
|
18
|
+
import { checkVolumeCaps, getDisallowedCustomTools, resolveToolPolicy } from "./tool-policy.js";
|
|
19
|
+
import type {
|
|
20
|
+
BlockCode,
|
|
21
|
+
CallerContext,
|
|
22
|
+
ReasoningLevel,
|
|
23
|
+
SageBudgetState,
|
|
24
|
+
SageConsultInput,
|
|
25
|
+
SageMode,
|
|
26
|
+
SageToolResult,
|
|
27
|
+
ToolProfile
|
|
28
|
+
} from "./types.js";
|
|
29
|
+
|
|
30
|
+
type InputSource = "interactive" | "rpc" | "extension";
|
|
31
|
+
|
|
32
|
+
type ModelLike = {
|
|
33
|
+
provider: string;
|
|
34
|
+
id: string;
|
|
35
|
+
name?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const ROLE_HINT_ENV_KEYS = ["TASKPLANE_ROLE", "TASKPLANE_AGENT_ROLE", "PI_AGENT_ROLE", "ORCH_AGENT_ROLE"];
|
|
39
|
+
|
|
40
|
+
const REASONING_LEVELS: ReasoningLevel[] = ["minimal", "low", "medium", "high", "xhigh"];
|
|
41
|
+
const TOOL_PROFILES: ToolProfile[] = ["none", "read-only-lite", "git-review-readonly", "custom-read-only"];
|
|
42
|
+
|
|
43
|
+
const SAGE_GUIDANCE = [
|
|
44
|
+
"You may call `sage_consult` for high-complexity reasoning, ambiguous debugging, risky architectural decisions, or explicit second-opinion requests.",
|
|
45
|
+
"Do not overuse Sage for routine edits.",
|
|
46
|
+
"Sage is advisory-only: treat output as recommendations and implement changes yourself.",
|
|
47
|
+
"Use one focused Sage question per consultation.",
|
|
48
|
+
"If user explicitly requests Sage/second opinion, prefer at least one Sage consultation unless blocked by hard safety limits."
|
|
49
|
+
].join("\n- ");
|
|
50
|
+
|
|
51
|
+
export default function registerSageExtension(pi: ExtensionAPI): void {
|
|
52
|
+
if (process.env.PI_SAGE_SUBAGENT === "1") {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const budgetState: SageBudgetState = {
|
|
57
|
+
currentTurn: 0,
|
|
58
|
+
callsThisTurn: 0,
|
|
59
|
+
sessionCalls: 0,
|
|
60
|
+
lastAutoTurn: undefined,
|
|
61
|
+
sessionCostTotal: 0
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
let lastInputSource: InputSource | undefined;
|
|
65
|
+
|
|
66
|
+
pi.on("input", (event) => {
|
|
67
|
+
if (event.source === "interactive" || event.source === "rpc" || event.source === "extension") {
|
|
68
|
+
lastInputSource = event.source;
|
|
69
|
+
}
|
|
70
|
+
return { action: "continue" };
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
pi.on("turn_start", () => {
|
|
74
|
+
budgetState.currentTurn += 1;
|
|
75
|
+
budgetState.callsThisTurn = 0;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
79
|
+
const loaded = loadSettings(ctx.cwd);
|
|
80
|
+
if (loaded.settings.enabled === false) return undefined;
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
systemPrompt: `${event.systemPrompt}\n\nSage guidance:\n- ${SAGE_GUIDANCE}`
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
pi.registerCommand("sage-settings", {
|
|
88
|
+
description: "Configure Sage model, limits, and policies",
|
|
89
|
+
handler: async (_args, ctx) => {
|
|
90
|
+
await runSettingsWizard(ctx);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
pi.registerTool({
|
|
95
|
+
name: "sage_consult",
|
|
96
|
+
label: "Sage Consult",
|
|
97
|
+
description: "Consult an isolated high-reasoning Sage subagent for advisory analysis.",
|
|
98
|
+
promptSnippet: "Consult Sage for complex second-opinion reasoning.",
|
|
99
|
+
promptGuidelines: [
|
|
100
|
+
"Use Sage for high-impact ambiguous tasks or explicit second-opinion requests.",
|
|
101
|
+
"Set force=true when the user explicitly asks for Sage to bypass soft limits.",
|
|
102
|
+
"After Sage returns, synthesize recommendations and continue execution."
|
|
103
|
+
],
|
|
104
|
+
parameters: Type.Object({
|
|
105
|
+
question: Type.String({ description: "Focused question to ask Sage" }),
|
|
106
|
+
context: Type.Optional(Type.String({ description: "Compressed relevant context" })),
|
|
107
|
+
files: Type.Optional(Type.Array(Type.String(), { description: "Relevant file paths" })),
|
|
108
|
+
evidence: Type.Optional(Type.Array(Type.String(), { description: "Logs, errors, or observations" })),
|
|
109
|
+
objective: Type.Optional(
|
|
110
|
+
Type.Union([
|
|
111
|
+
Type.Literal("debug"),
|
|
112
|
+
Type.Literal("design"),
|
|
113
|
+
Type.Literal("review"),
|
|
114
|
+
Type.Literal("refactor"),
|
|
115
|
+
Type.Literal("general")
|
|
116
|
+
])
|
|
117
|
+
),
|
|
118
|
+
urgency: Type.Optional(
|
|
119
|
+
Type.Union([Type.Literal("low"), Type.Literal("medium"), Type.Literal("high")])
|
|
120
|
+
),
|
|
121
|
+
force: Type.Optional(
|
|
122
|
+
Type.Boolean({ description: "Bypass soft limits for explicit user-requested consultations" })
|
|
123
|
+
)
|
|
124
|
+
}),
|
|
125
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx): Promise<SageToolResult> {
|
|
126
|
+
const input = params as SageConsultInput;
|
|
127
|
+
const loaded = loadSettings(ctx.cwd);
|
|
128
|
+
const settings = loaded.settings;
|
|
129
|
+
const mode: SageMode = input.force ? "user-requested" : "autonomous";
|
|
130
|
+
|
|
131
|
+
const modelSelection = resolveModelSpec(settings.model, ctx.modelRegistry.getAvailable(), ctx.model);
|
|
132
|
+
const resolvedModel = modelSelection.modelArg;
|
|
133
|
+
|
|
134
|
+
if (!settings.enabled) {
|
|
135
|
+
return makeBlockedResult({
|
|
136
|
+
mode,
|
|
137
|
+
model: resolvedModel,
|
|
138
|
+
reasoningLevel: settings.reasoningLevel,
|
|
139
|
+
blockCode: "disabled",
|
|
140
|
+
reason: "Sage is disabled",
|
|
141
|
+
allowedByContext: true,
|
|
142
|
+
allowedByBudget: false
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const callerContext = buildCallerContext(lastInputSource, ctx.hasUI);
|
|
147
|
+
const eligibility = isEligibleCaller(callerContext);
|
|
148
|
+
if (!eligibility.ok) {
|
|
149
|
+
return makeBlockedResult({
|
|
150
|
+
mode,
|
|
151
|
+
model: resolvedModel,
|
|
152
|
+
reasoningLevel: settings.reasoningLevel,
|
|
153
|
+
blockCode: eligibility.blockCode ?? "unknown-context",
|
|
154
|
+
reason: eligibility.reason,
|
|
155
|
+
allowedByContext: false,
|
|
156
|
+
allowedByBudget: false
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!resolvedModel) {
|
|
161
|
+
return makeBlockedResult({
|
|
162
|
+
mode,
|
|
163
|
+
model: "unavailable",
|
|
164
|
+
reasoningLevel: settings.reasoningLevel,
|
|
165
|
+
blockCode: "model-unavailable",
|
|
166
|
+
reason: "No available model configured for Sage",
|
|
167
|
+
allowedByContext: true,
|
|
168
|
+
allowedByBudget: false
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (isHardCostCapExceeded(settings, budgetState)) {
|
|
173
|
+
return makeBlockedResult({
|
|
174
|
+
mode,
|
|
175
|
+
model: resolvedModel,
|
|
176
|
+
reasoningLevel: settings.reasoningLevel,
|
|
177
|
+
blockCode: "cost-cap",
|
|
178
|
+
reason: "Sage session cost cap exceeded",
|
|
179
|
+
allowedByContext: true,
|
|
180
|
+
allowedByBudget: false
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const softBudget = evaluateSoftBudget({
|
|
185
|
+
settings,
|
|
186
|
+
budgetState,
|
|
187
|
+
mode,
|
|
188
|
+
force: input.force === true,
|
|
189
|
+
questionLength: input.question.length,
|
|
190
|
+
contextLength: input.context?.length ?? 0
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (!softBudget.ok) {
|
|
194
|
+
return makeBlockedResult({
|
|
195
|
+
mode,
|
|
196
|
+
model: resolvedModel,
|
|
197
|
+
reasoningLevel: settings.reasoningLevel,
|
|
198
|
+
blockCode: "soft-limit",
|
|
199
|
+
reason: softBudget.reason,
|
|
200
|
+
allowedByContext: true,
|
|
201
|
+
allowedByBudget: false
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const disallowedCustomTools = getDisallowedCustomTools(settings.toolPolicy.customAllowedTools);
|
|
206
|
+
if (disallowedCustomTools.length > 0) {
|
|
207
|
+
return makeBlockedResult({
|
|
208
|
+
mode,
|
|
209
|
+
model: resolvedModel,
|
|
210
|
+
reasoningLevel: settings.reasoningLevel,
|
|
211
|
+
blockCode: "tool-disallowed",
|
|
212
|
+
reason: `Custom tool list contains disallowed tools: ${disallowedCustomTools.join(", ")}`,
|
|
213
|
+
allowedByContext: true,
|
|
214
|
+
allowedByBudget: false
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const toolPolicy = resolveToolPolicy(settings.toolPolicy);
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const runnerResult = await runSageSingleShot({
|
|
222
|
+
cwd: ctx.cwd,
|
|
223
|
+
model: resolvedModel,
|
|
224
|
+
reasoningLevel: settings.reasoningLevel,
|
|
225
|
+
timeoutMs: settings.timeoutMs,
|
|
226
|
+
question: input.question,
|
|
227
|
+
context: input.context,
|
|
228
|
+
files: input.files,
|
|
229
|
+
evidence: input.evidence,
|
|
230
|
+
objective: input.objective,
|
|
231
|
+
urgency: input.urgency,
|
|
232
|
+
toolPolicy: settings.toolPolicy,
|
|
233
|
+
signal
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const volume = checkVolumeCaps(runnerResult.toolUsage, toolPolicy);
|
|
237
|
+
if (!volume.ok) {
|
|
238
|
+
return makeBlockedResult({
|
|
239
|
+
mode,
|
|
240
|
+
model: resolvedModel,
|
|
241
|
+
reasoningLevel: settings.reasoningLevel,
|
|
242
|
+
blockCode: volume.blockCode ?? "volume-cap",
|
|
243
|
+
reason: volume.reason,
|
|
244
|
+
allowedByContext: true,
|
|
245
|
+
allowedByBudget: false
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
budgetState.callsThisTurn += 1;
|
|
250
|
+
budgetState.sessionCalls += 1;
|
|
251
|
+
if (mode === "autonomous") budgetState.lastAutoTurn = budgetState.currentTurn;
|
|
252
|
+
if (runnerResult.usage?.costTotal) budgetState.sessionCostTotal += runnerResult.usage.costTotal;
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
content: [{ type: "text", text: runnerResult.text }],
|
|
256
|
+
details: {
|
|
257
|
+
model: resolvedModel,
|
|
258
|
+
reasoningLevel: settings.reasoningLevel,
|
|
259
|
+
latencyMs: runnerResult.latencyMs,
|
|
260
|
+
stopReason: runnerResult.stopReason,
|
|
261
|
+
usage: runnerResult.usage,
|
|
262
|
+
toolUsage: runnerResult.toolUsage,
|
|
263
|
+
policy: {
|
|
264
|
+
mode,
|
|
265
|
+
allowedByContext: true,
|
|
266
|
+
contextReason: "eligible",
|
|
267
|
+
allowedByBudget: true,
|
|
268
|
+
budgetReason: softBudget.reason
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
} catch (error) {
|
|
273
|
+
const message = error instanceof Error ? error.message : "Unknown Sage failure";
|
|
274
|
+
|
|
275
|
+
if (isSageRunnerPolicyError(error)) {
|
|
276
|
+
return makeBlockedResult({
|
|
277
|
+
mode,
|
|
278
|
+
model: resolvedModel,
|
|
279
|
+
reasoningLevel: settings.reasoningLevel,
|
|
280
|
+
blockCode: error.blockCode,
|
|
281
|
+
reason: error.message,
|
|
282
|
+
allowedByContext: true,
|
|
283
|
+
allowedByBudget: false
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (message.toLowerCase().includes("timed out")) {
|
|
288
|
+
return makeBlockedResult({
|
|
289
|
+
mode,
|
|
290
|
+
model: resolvedModel,
|
|
291
|
+
reasoningLevel: settings.reasoningLevel,
|
|
292
|
+
blockCode: "timeout",
|
|
293
|
+
reason: message,
|
|
294
|
+
allowedByContext: true,
|
|
295
|
+
allowedByBudget: false
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const blockCode: BlockCode = "execution-error";
|
|
300
|
+
return {
|
|
301
|
+
content: [{ type: "text", text: `Sage execution failed: ${message}` }],
|
|
302
|
+
details: {
|
|
303
|
+
model: resolvedModel,
|
|
304
|
+
reasoningLevel: settings.reasoningLevel,
|
|
305
|
+
latencyMs: 0,
|
|
306
|
+
stopReason: "error",
|
|
307
|
+
policy: {
|
|
308
|
+
mode,
|
|
309
|
+
allowedByContext: true,
|
|
310
|
+
contextReason: "eligible",
|
|
311
|
+
blockCode,
|
|
312
|
+
allowedByBudget: true,
|
|
313
|
+
budgetReason: softBudget.reason
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
isError: true
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function buildCallerContext(lastInputSource: InputSource | undefined, hasUI: boolean): CallerContext | null {
|
|
324
|
+
if (!lastInputSource) return null;
|
|
325
|
+
|
|
326
|
+
const roleHint = getRoleHint();
|
|
327
|
+
const isRpcSource = lastInputSource === "rpc";
|
|
328
|
+
const isSubagent = process.env.PI_SAGE_SUBAGENT === "1";
|
|
329
|
+
const interactive = hasUI && lastInputSource === "interactive";
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
session: {
|
|
333
|
+
interactive
|
|
334
|
+
},
|
|
335
|
+
agent: {
|
|
336
|
+
role: roleHint ?? "primary",
|
|
337
|
+
isSubagent,
|
|
338
|
+
isRpcOrchestrated: isRpcSource || Boolean(roleHint && roleHint !== "primary")
|
|
339
|
+
},
|
|
340
|
+
runtime: {
|
|
341
|
+
mode: process.env.CI ? "ci" : hasUI ? (isRpcSource ? "rpc" : "interactive") : "non-interactive"
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function getRoleHint(): string | undefined {
|
|
347
|
+
for (const key of ROLE_HINT_ENV_KEYS) {
|
|
348
|
+
const value = process.env[key];
|
|
349
|
+
if (value) return value.trim().toLowerCase();
|
|
350
|
+
}
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function runSettingsWizard(ctx: ExtensionCommandContext): Promise<void> {
|
|
355
|
+
if (!ctx.hasUI) {
|
|
356
|
+
ctx.ui.notify("/sage-settings requires interactive UI", "warning");
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const loaded = loadSettings(ctx.cwd);
|
|
361
|
+
let draft = mergeSettings(loaded.settings);
|
|
362
|
+
let scope: SettingsScope = loaded.source === "global" ? "global" : "project";
|
|
363
|
+
|
|
364
|
+
while (true) {
|
|
365
|
+
const action = await ctx.ui.select("Sage settings", [
|
|
366
|
+
`Enabled: ${onOff(draft.enabled)}`,
|
|
367
|
+
`Autonomous mode: ${onOff(draft.autonomousEnabled)}`,
|
|
368
|
+
`Model: ${draft.model}`,
|
|
369
|
+
`Reasoning level: ${draft.reasoningLevel}`,
|
|
370
|
+
`Timeout ms: ${draft.timeoutMs}`,
|
|
371
|
+
`Max calls/turn: ${draft.maxCallsPerTurn}`,
|
|
372
|
+
`Max calls/session: ${draft.maxCallsPerSession}`,
|
|
373
|
+
`Cooldown turns: ${draft.cooldownTurnsBetweenAutoCalls}`,
|
|
374
|
+
`Tool profile: ${draft.toolPolicy.profile}`,
|
|
375
|
+
`Custom tools: ${(draft.toolPolicy.customAllowedTools ?? []).join(",") || "(none)"}`,
|
|
376
|
+
`Max tool calls: ${draft.toolPolicy.maxToolCalls ?? 10}`,
|
|
377
|
+
`Max files read: ${draft.toolPolicy.maxFilesRead ?? 8}`,
|
|
378
|
+
`Max bytes/file: ${draft.toolPolicy.maxBytesPerFile ?? 200 * 1024}`,
|
|
379
|
+
`Max total bytes: ${draft.toolPolicy.maxTotalBytesRead ?? 1024 * 1024}`,
|
|
380
|
+
`Sensitive denylist: ${(draft.toolPolicy.sensitivePathDenylist ?? []).join(",")}`,
|
|
381
|
+
`Cost cap/session: ${draft.maxEstimatedCostPerSession ?? "(none)"}`,
|
|
382
|
+
`Save scope: ${scope}`,
|
|
383
|
+
"Test Sage call",
|
|
384
|
+
"Save and exit",
|
|
385
|
+
"Exit without saving"
|
|
386
|
+
]);
|
|
387
|
+
|
|
388
|
+
if (!action || action === "Exit without saving") {
|
|
389
|
+
ctx.ui.notify("Sage settings unchanged", "info");
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (action === "Save and exit") {
|
|
394
|
+
const target = getSettingsPathForScope(ctx.cwd, scope);
|
|
395
|
+
saveSettings(target, draft);
|
|
396
|
+
ctx.ui.notify(`Saved Sage settings to ${target}`, "info");
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (action === "Test Sage call") {
|
|
401
|
+
await runSettingsTestCall(ctx, draft);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (action.startsWith("Enabled:")) {
|
|
406
|
+
draft = { ...draft, enabled: !draft.enabled };
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
if (action.startsWith("Autonomous mode:")) {
|
|
410
|
+
draft = { ...draft, autonomousEnabled: !draft.autonomousEnabled };
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (action.startsWith("Model:")) {
|
|
414
|
+
const selected = await pickModel(ctx);
|
|
415
|
+
if (selected) draft = { ...draft, model: selected };
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (action.startsWith("Reasoning level:")) {
|
|
419
|
+
const selected = await ctx.ui.select("Reasoning level", [...REASONING_LEVELS]);
|
|
420
|
+
if (selected && REASONING_LEVELS.includes(selected as ReasoningLevel)) {
|
|
421
|
+
draft = { ...draft, reasoningLevel: selected as ReasoningLevel };
|
|
422
|
+
}
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (action.startsWith("Timeout ms:")) {
|
|
426
|
+
draft = await setNumberSetting(ctx, draft, "timeoutMs", "Timeout in milliseconds", 1000);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (action.startsWith("Max calls/turn:")) {
|
|
430
|
+
draft = await setNumberSetting(ctx, draft, "maxCallsPerTurn", "Max Sage calls per turn", 1);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
if (action.startsWith("Max calls/session:")) {
|
|
434
|
+
draft = await setNumberSetting(ctx, draft, "maxCallsPerSession", "Max Sage calls per session", 1);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if (action.startsWith("Cooldown turns:")) {
|
|
438
|
+
draft = await setNumberSetting(ctx, draft, "cooldownTurnsBetweenAutoCalls", "Cooldown turns", 0);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (action.startsWith("Tool profile:")) {
|
|
442
|
+
const selected = await ctx.ui.select("Tool profile", [...TOOL_PROFILES]);
|
|
443
|
+
if (selected && TOOL_PROFILES.includes(selected as ToolProfile)) {
|
|
444
|
+
draft = { ...draft, toolPolicy: { ...draft.toolPolicy, profile: selected as ToolProfile } };
|
|
445
|
+
}
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (action.startsWith("Custom tools:")) {
|
|
449
|
+
const value = await ctx.ui.input("Comma-separated custom tools", "ls,glob,grep,read");
|
|
450
|
+
if (value !== undefined) {
|
|
451
|
+
const tools = value
|
|
452
|
+
.split(",")
|
|
453
|
+
.map((item) => item.trim())
|
|
454
|
+
.filter(Boolean);
|
|
455
|
+
draft = { ...draft, toolPolicy: { ...draft.toolPolicy, customAllowedTools: tools } };
|
|
456
|
+
}
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (action.startsWith("Max tool calls:")) {
|
|
460
|
+
draft = await setToolPolicyNumberSetting(ctx, draft, "maxToolCalls", "Max tool calls", 1);
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (action.startsWith("Max files read:")) {
|
|
464
|
+
draft = await setToolPolicyNumberSetting(ctx, draft, "maxFilesRead", "Max files read", 1);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (action.startsWith("Max bytes/file:")) {
|
|
468
|
+
draft = await setToolPolicyNumberSetting(ctx, draft, "maxBytesPerFile", "Max bytes per file", 1024);
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (action.startsWith("Max total bytes:")) {
|
|
472
|
+
draft = await setToolPolicyNumberSetting(ctx, draft, "maxTotalBytesRead", "Max total bytes", 1024);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (action.startsWith("Sensitive denylist:")) {
|
|
476
|
+
const value = await ctx.ui.input(
|
|
477
|
+
"Comma-separated sensitive path denylist",
|
|
478
|
+
(draft.toolPolicy.sensitivePathDenylist ?? []).join(",")
|
|
479
|
+
);
|
|
480
|
+
if (value !== undefined) {
|
|
481
|
+
const denylist = value
|
|
482
|
+
.split(",")
|
|
483
|
+
.map((item) => item.trim())
|
|
484
|
+
.filter(Boolean);
|
|
485
|
+
draft = { ...draft, toolPolicy: { ...draft.toolPolicy, sensitivePathDenylist: denylist } };
|
|
486
|
+
}
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (action.startsWith("Cost cap/session:")) {
|
|
490
|
+
const value = await ctx.ui.input("Cost cap per session (blank clears)", String(draft.maxEstimatedCostPerSession ?? ""));
|
|
491
|
+
if (value === undefined) continue;
|
|
492
|
+
const trimmed = value.trim();
|
|
493
|
+
if (trimmed.length === 0) {
|
|
494
|
+
const copy = { ...draft };
|
|
495
|
+
delete copy.maxEstimatedCostPerSession;
|
|
496
|
+
draft = copy;
|
|
497
|
+
} else {
|
|
498
|
+
const parsed = Number(trimmed);
|
|
499
|
+
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
500
|
+
draft = { ...draft, maxEstimatedCostPerSession: parsed };
|
|
501
|
+
} else {
|
|
502
|
+
ctx.ui.notify("Invalid cost cap value", "warning");
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (action.startsWith("Save scope:")) {
|
|
508
|
+
const selected = await ctx.ui.select("Save scope", ["project", "global"]);
|
|
509
|
+
if (selected === "project" || selected === "global") scope = selected;
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function pickModel(ctx: ExtensionContext): Promise<string | undefined> {
|
|
516
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
517
|
+
if (available.length === 0) {
|
|
518
|
+
ctx.ui.notify("No available models found", "warning");
|
|
519
|
+
return undefined;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const sorted = [...available].sort((a, b) => {
|
|
523
|
+
const left = `${a.provider}/${a.id}`.toLowerCase();
|
|
524
|
+
const right = `${b.provider}/${b.id}`.toLowerCase();
|
|
525
|
+
return left.localeCompare(right);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
const options = sorted.slice(0, 200).map((model) => `${model.provider}/${model.id}${model.name ? ` — ${model.name}` : ""}`);
|
|
529
|
+
const selected = await ctx.ui.select("Choose Sage model", options);
|
|
530
|
+
if (!selected) return undefined;
|
|
531
|
+
return selected.split(" — ")[0];
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function resolveModelSpec(
|
|
535
|
+
configuredModel: string,
|
|
536
|
+
availableModels: ModelLike[],
|
|
537
|
+
currentModel: ModelLike | undefined
|
|
538
|
+
): { modelArg: string | undefined; reason: string } {
|
|
539
|
+
if (availableModels.length === 0) {
|
|
540
|
+
return { modelArg: undefined, reason: "no available models" };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const exactConfigured = availableModels.find((model) => `${model.provider}/${model.id}` === configuredModel);
|
|
544
|
+
if (exactConfigured) {
|
|
545
|
+
return { modelArg: `${exactConfigured.provider}/${exactConfigured.id}`, reason: "configured exact match" };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const fuzzyConfigured = configuredModel.trim().toLowerCase();
|
|
549
|
+
if (fuzzyConfigured) {
|
|
550
|
+
const fuzzyMatch = availableModels.find((model) => {
|
|
551
|
+
const composite = `${model.provider}/${model.id}`.toLowerCase();
|
|
552
|
+
const name = model.name?.toLowerCase() ?? "";
|
|
553
|
+
return composite.includes(fuzzyConfigured) || name.includes(fuzzyConfigured);
|
|
554
|
+
});
|
|
555
|
+
if (fuzzyMatch) {
|
|
556
|
+
return { modelArg: `${fuzzyMatch.provider}/${fuzzyMatch.id}`, reason: "configured fuzzy match" };
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const preferredOpus = availableModels.find((model) => {
|
|
561
|
+
const id = model.id.toLowerCase();
|
|
562
|
+
const name = (model.name ?? "").toLowerCase();
|
|
563
|
+
return model.provider === "anthropic" && (id.includes("opus") || name.includes("opus"));
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
if (preferredOpus) {
|
|
567
|
+
return { modelArg: `${preferredOpus.provider}/${preferredOpus.id}`, reason: "anthropic opus fallback" };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (currentModel) {
|
|
571
|
+
const present = availableModels.find(
|
|
572
|
+
(model) => model.provider === currentModel.provider && model.id === currentModel.id
|
|
573
|
+
);
|
|
574
|
+
if (present) {
|
|
575
|
+
return { modelArg: `${present.provider}/${present.id}`, reason: "current model fallback" };
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const first = availableModels.at(0);
|
|
580
|
+
if (!first) return { modelArg: undefined, reason: "no available models" };
|
|
581
|
+
return { modelArg: `${first.provider}/${first.id}`, reason: "first available fallback" };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function onOff(value: boolean): string {
|
|
585
|
+
return value ? "on" : "off";
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function setNumberSetting(
|
|
589
|
+
ctx: ExtensionContext,
|
|
590
|
+
settings: SageSettings,
|
|
591
|
+
key: "timeoutMs" | "maxCallsPerTurn" | "maxCallsPerSession" | "cooldownTurnsBetweenAutoCalls",
|
|
592
|
+
title: string,
|
|
593
|
+
min: number
|
|
594
|
+
): Promise<SageSettings> {
|
|
595
|
+
const current = String(settings[key]);
|
|
596
|
+
const value = await ctx.ui.input(title, current);
|
|
597
|
+
if (value === undefined) return settings;
|
|
598
|
+
|
|
599
|
+
const parsed = Number(value.trim());
|
|
600
|
+
if (Number.isFinite(parsed) === false) {
|
|
601
|
+
ctx.ui.notify("Invalid numeric value", "warning");
|
|
602
|
+
return settings;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const normalized = Math.max(min, Math.floor(parsed));
|
|
606
|
+
return { ...settings, [key]: normalized };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function setToolPolicyNumberSetting(
|
|
610
|
+
ctx: ExtensionContext,
|
|
611
|
+
settings: SageSettings,
|
|
612
|
+
key: "maxToolCalls" | "maxFilesRead" | "maxBytesPerFile" | "maxTotalBytesRead",
|
|
613
|
+
title: string,
|
|
614
|
+
min: number
|
|
615
|
+
): Promise<SageSettings> {
|
|
616
|
+
const current = String(settings.toolPolicy[key] ?? "");
|
|
617
|
+
const value = await ctx.ui.input(title, current);
|
|
618
|
+
if (value === undefined) return settings;
|
|
619
|
+
|
|
620
|
+
const parsed = Number(value.trim());
|
|
621
|
+
if (Number.isFinite(parsed) === false) {
|
|
622
|
+
ctx.ui.notify("Invalid numeric value", "warning");
|
|
623
|
+
return settings;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const normalized = Math.max(min, Math.floor(parsed));
|
|
627
|
+
return {
|
|
628
|
+
...settings,
|
|
629
|
+
toolPolicy: {
|
|
630
|
+
...settings.toolPolicy,
|
|
631
|
+
[key]: normalized
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async function runSettingsTestCall(ctx: ExtensionContext, settings: SageSettings): Promise<void> {
|
|
637
|
+
const modelSelection = resolveModelSpec(settings.model, ctx.modelRegistry.getAvailable(), ctx.model);
|
|
638
|
+
const resolvedModel = modelSelection.modelArg;
|
|
639
|
+
if (!resolvedModel) {
|
|
640
|
+
ctx.ui.notify("Cannot test Sage: no available model", "warning");
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
try {
|
|
645
|
+
const result = await runSageSingleShot({
|
|
646
|
+
cwd: ctx.cwd,
|
|
647
|
+
model: resolvedModel,
|
|
648
|
+
reasoningLevel: settings.reasoningLevel,
|
|
649
|
+
timeoutMs: Math.min(settings.timeoutMs, 45_000),
|
|
650
|
+
question: "Reply with 'Sage test OK' and one short sentence.",
|
|
651
|
+
toolPolicy: settings.toolPolicy
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
ctx.ui.notify(`Sage test succeeded (${result.latencyMs}ms)`, "info");
|
|
655
|
+
} catch (error) {
|
|
656
|
+
const message = error instanceof Error ? error.message : "Unknown test failure";
|
|
657
|
+
ctx.ui.notify(`Sage test failed: ${message}`, "error");
|
|
658
|
+
}
|
|
659
|
+
}
|