pi-devin-fusion 0.1.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/README.md +130 -0
- package/package.json +44 -0
- package/src/config.ts +73 -0
- package/src/context.ts +86 -0
- package/src/executor.ts +127 -0
- package/src/executor_policy.ts +38 -0
- package/src/index.ts +509 -0
- package/src/llm.ts +248 -0
- package/src/models.ts +53 -0
- package/src/prompts.ts +23 -0
- package/src/tools.ts +108 -0
- package/src/types.ts +70 -0
- package/src/ui.ts +370 -0
- package/src/utils.ts +71 -0
- package/tsconfig.json +16 -0
package/src/llm.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Low-level LLM calls for pi-devin-fusion (executor loop).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
complete,
|
|
7
|
+
type AssistantMessage,
|
|
8
|
+
type Message,
|
|
9
|
+
type Tool,
|
|
10
|
+
type ToolCall,
|
|
11
|
+
type ToolResultMessage,
|
|
12
|
+
} from "@earendil-works/pi-ai/compat";
|
|
13
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
14
|
+
import type { ProviderStreamOptions } from "@earendil-works/pi-ai/compat";
|
|
15
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { TOOL_OUTPUT_MAX_BYTES } from "./config.ts";
|
|
18
|
+
import type { ExecutorToolDef } from "./tools.ts";
|
|
19
|
+
import { truncateToBytes } from "./utils.ts";
|
|
20
|
+
|
|
21
|
+
type ToolContent = ToolResultMessage["content"];
|
|
22
|
+
|
|
23
|
+
interface CompleteOptions extends ProviderStreamOptions {
|
|
24
|
+
apiKey: string;
|
|
25
|
+
headers?: Record<string, string>;
|
|
26
|
+
maxTokens: number;
|
|
27
|
+
temperature?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function buildCompleteOptions(
|
|
31
|
+
registry: ModelRegistry,
|
|
32
|
+
model: Model<Api>,
|
|
33
|
+
maxTokens: number,
|
|
34
|
+
temperature: number,
|
|
35
|
+
signal: AbortSignal | undefined,
|
|
36
|
+
): Promise<CompleteOptions> {
|
|
37
|
+
const auth = await registry.getApiKeyAndHeaders(model);
|
|
38
|
+
if (!auth.ok || !auth.apiKey) {
|
|
39
|
+
throw new Error(auth.ok ? `No API key for ${model.provider}/${model.id}` : auth.error);
|
|
40
|
+
}
|
|
41
|
+
const options: CompleteOptions = {
|
|
42
|
+
apiKey: auth.apiKey,
|
|
43
|
+
headers: auth.headers,
|
|
44
|
+
signal,
|
|
45
|
+
maxTokens,
|
|
46
|
+
};
|
|
47
|
+
// Some models (e.g. Anthropic Claude Opus 4.7+, OpenAI Codex) reject temperature.
|
|
48
|
+
if (getSupportsTemperature(model)) {
|
|
49
|
+
options.temperature = temperature;
|
|
50
|
+
}
|
|
51
|
+
return options;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function callModelText(
|
|
55
|
+
registry: ModelRegistry,
|
|
56
|
+
model: Model<Api>,
|
|
57
|
+
systemPrompt: string,
|
|
58
|
+
userText: string,
|
|
59
|
+
maxTokens: number,
|
|
60
|
+
temperature: number,
|
|
61
|
+
signal: AbortSignal | undefined,
|
|
62
|
+
): Promise<AssistantMessage> {
|
|
63
|
+
const options = await buildCompleteOptions(registry, model, maxTokens, temperature, signal);
|
|
64
|
+
return runComplete(
|
|
65
|
+
model,
|
|
66
|
+
{ systemPrompt, messages: [{ role: "user", content: userText, timestamp: Date.now() }] },
|
|
67
|
+
options,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ToolLoopResult {
|
|
72
|
+
/** Final assistant message (its text is the executor answer). */
|
|
73
|
+
message: AssistantMessage;
|
|
74
|
+
/** Number of complete() calls made. */
|
|
75
|
+
turns: number;
|
|
76
|
+
/** Every tool call attempted, with success flag. */
|
|
77
|
+
toolCalls: Array<{ name: string; ok: boolean }>;
|
|
78
|
+
/** True when the loop was forced to finalize (cap or circuit breaker) before a natural stop. */
|
|
79
|
+
cappedOut: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Run a model through an internal agentic tool loop, bounded by `maxToolCalls`.
|
|
84
|
+
*
|
|
85
|
+
* The model may call the provided tools across multiple turns; each tool result
|
|
86
|
+
* is fed back until the model returns text on its own, the cap is reached, or a
|
|
87
|
+
* circuit breaker trips. On any forced finish, a final tool-free completion is
|
|
88
|
+
* requested so the executor always returns a text answer.
|
|
89
|
+
*/
|
|
90
|
+
export async function callModelWithTools(
|
|
91
|
+
registry: ModelRegistry,
|
|
92
|
+
model: Model<Api>,
|
|
93
|
+
systemPrompt: string,
|
|
94
|
+
userText: string,
|
|
95
|
+
maxTokens: number,
|
|
96
|
+
temperature: number,
|
|
97
|
+
signal: AbortSignal | undefined,
|
|
98
|
+
toolDefs: ExecutorToolDef[],
|
|
99
|
+
maxToolCalls: number,
|
|
100
|
+
ctx: ExtensionContext,
|
|
101
|
+
onToolEvent?: (ev: { name: string; turn: number; ok: boolean }) => void,
|
|
102
|
+
): Promise<ToolLoopResult> {
|
|
103
|
+
const options = await buildCompleteOptions(registry, model, maxTokens, temperature, signal);
|
|
104
|
+
const tools: Tool[] = toolDefs.map((d) => ({ name: d.name, description: d.description, parameters: d.parameters }));
|
|
105
|
+
const byName = new Map(toolDefs.map((d) => [d.name, d]));
|
|
106
|
+
|
|
107
|
+
const messages: Message[] = [{ role: "user", content: userText, timestamp: Date.now() }];
|
|
108
|
+
const toolCalls: Array<{ name: string; ok: boolean }> = [];
|
|
109
|
+
let turns = 0;
|
|
110
|
+
let used = 0;
|
|
111
|
+
let lastKey: string | undefined;
|
|
112
|
+
let repeatRun = 0;
|
|
113
|
+
let errorStreak = 0;
|
|
114
|
+
|
|
115
|
+
while (true) {
|
|
116
|
+
const resp = await runComplete(model, { systemPrompt, messages, tools }, options);
|
|
117
|
+
turns++;
|
|
118
|
+
|
|
119
|
+
const calls = resp.content.filter((c): c is ToolCall => c.type === "toolCall");
|
|
120
|
+
if (resp.stopReason !== "toolUse" || calls.length === 0) {
|
|
121
|
+
return { message: resp, turns, toolCalls, cappedOut: false };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
messages.push(resp);
|
|
125
|
+
let forceFinalize = false;
|
|
126
|
+
|
|
127
|
+
for (const tc of calls) {
|
|
128
|
+
if (forceFinalize || used >= maxToolCalls) {
|
|
129
|
+
// Every ToolCall must get a paired result or the next request 400s.
|
|
130
|
+
messages.push(syntheticResult(tc, forceFinalize ? "stopped: repeated or failing tool calls" : "tool-call budget exhausted"));
|
|
131
|
+
toolCalls.push({ name: tc.name, ok: false });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const ok = await executeToolCall(tc, byName.get(tc.name), signal, ctx, messages);
|
|
136
|
+
used++;
|
|
137
|
+
toolCalls.push({ name: tc.name, ok });
|
|
138
|
+
onToolEvent?.({ name: tc.name, turn: turns, ok });
|
|
139
|
+
|
|
140
|
+
// Circuit breaker for TIGHT stuck-loops only: the exact same call (tool + args)
|
|
141
|
+
// 3x in a row, or 3 consecutive errors. The key includes arguments, so varied
|
|
142
|
+
// context-gathering — even many calls of the same tool with different paths/queries
|
|
143
|
+
// — never trips it; only a model spinning on the identical call does. maxToolCalls
|
|
144
|
+
// remains the hard upper bound.
|
|
145
|
+
const key = `${tc.name}:${JSON.stringify(tc.arguments)}`;
|
|
146
|
+
repeatRun = key === lastKey ? repeatRun + 1 : 1;
|
|
147
|
+
lastKey = key;
|
|
148
|
+
errorStreak = ok ? 0 : errorStreak + 1;
|
|
149
|
+
if (repeatRun >= 3 || errorStreak >= 3) {
|
|
150
|
+
forceFinalize = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (forceFinalize || used >= maxToolCalls) {
|
|
155
|
+
// Tools exhausted or looping: ask for the final answer with tools omitted, and
|
|
156
|
+
// nudge via the system prompt (some models go silent when tools disappear unless
|
|
157
|
+
// explicitly told to answer now). System-prompt nudge avoids an illegal trailing
|
|
158
|
+
// user message after tool results.
|
|
159
|
+
const finalSystem = `${systemPrompt}\n\nYou have reached the tool-call limit. Write your complete final answer now using only what you have already gathered — do not request any more tools.`;
|
|
160
|
+
const finalMsg = await runComplete(model, { systemPrompt: finalSystem, messages }, options);
|
|
161
|
+
turns++;
|
|
162
|
+
return { message: finalMsg, turns, toolCalls, cappedOut: true };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function runComplete(
|
|
168
|
+
model: Model<Api>,
|
|
169
|
+
context: { systemPrompt: string; messages: Message[]; tools?: Tool[] },
|
|
170
|
+
options: CompleteOptions,
|
|
171
|
+
): Promise<AssistantMessage> {
|
|
172
|
+
const resp = await complete(model, context, options);
|
|
173
|
+
if (resp.stopReason === "error" || resp.stopReason === "aborted") {
|
|
174
|
+
throw new Error(resp.errorMessage ?? `Model stopped with reason: ${resp.stopReason}`);
|
|
175
|
+
}
|
|
176
|
+
return resp;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Execute one tool call, push its result message, and report success. Never throws. */
|
|
180
|
+
async function executeToolCall(
|
|
181
|
+
tc: ToolCall,
|
|
182
|
+
def: ExecutorToolDef | undefined,
|
|
183
|
+
signal: AbortSignal | undefined,
|
|
184
|
+
ctx: ExtensionContext,
|
|
185
|
+
messages: Message[],
|
|
186
|
+
): Promise<boolean> {
|
|
187
|
+
try {
|
|
188
|
+
if (!def) throw new Error(`unknown tool: ${tc.name}`);
|
|
189
|
+
// tc.arguments is already a parsed object — pass it straight through.
|
|
190
|
+
// Cancellation flows through the explicit `signal` arg, which every built-in
|
|
191
|
+
// tool honors (verified); no ctx.signal override is needed.
|
|
192
|
+
const out = await def.execute(tc.id, tc.arguments, signal, undefined, ctx);
|
|
193
|
+
messages.push({
|
|
194
|
+
role: "toolResult",
|
|
195
|
+
toolCallId: tc.id,
|
|
196
|
+
toolName: tc.name,
|
|
197
|
+
content: truncateToolContent(out.content),
|
|
198
|
+
isError: false,
|
|
199
|
+
timestamp: Date.now(),
|
|
200
|
+
});
|
|
201
|
+
return true;
|
|
202
|
+
} catch (err) {
|
|
203
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
204
|
+
messages.push({
|
|
205
|
+
role: "toolResult",
|
|
206
|
+
toolCallId: tc.id,
|
|
207
|
+
toolName: tc.name,
|
|
208
|
+
content: [{ type: "text", text: `Error: ${text}` }],
|
|
209
|
+
isError: true,
|
|
210
|
+
timestamp: Date.now(),
|
|
211
|
+
});
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function syntheticResult(tc: ToolCall, text: string): ToolResultMessage {
|
|
217
|
+
return {
|
|
218
|
+
role: "toolResult",
|
|
219
|
+
toolCallId: tc.id,
|
|
220
|
+
toolName: tc.name,
|
|
221
|
+
content: [{ type: "text", text }],
|
|
222
|
+
isError: true,
|
|
223
|
+
timestamp: Date.now(),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Bound tool output before it re-enters the loop transcript and the final answer. */
|
|
228
|
+
function truncateToolContent(content: ToolContent): ToolContent {
|
|
229
|
+
return content.map((part) => {
|
|
230
|
+
if (part.type !== "text") return part;
|
|
231
|
+
const truncated = truncateToBytes(part.text, TOOL_OUTPUT_MAX_BYTES, "\n…[truncated]");
|
|
232
|
+
return truncated === part.text ? part : { type: "text", text: truncated };
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function getSupportsTemperature(model: Model<Api>): boolean {
|
|
237
|
+
// Anthropic encodes this in model metadata (e.g. Opus 4.7+ set supportsTemperature:false,
|
|
238
|
+
// some other providers omit the flag). Treat undefined as supported to avoid breaking calls.
|
|
239
|
+
const meta = model as unknown as { supportsTemperature?: boolean };
|
|
240
|
+
return meta.supportsTemperature !== false;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function getTextContent(message: AssistantMessage): string {
|
|
244
|
+
const textParts = message.content
|
|
245
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
246
|
+
.map((c) => c.text);
|
|
247
|
+
return textParts.join("\n").trim();
|
|
248
|
+
}
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model resolution and executor selection for pi-devin-fusion.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
6
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
export function modelDisplay(model: Model<Api>): string {
|
|
9
|
+
return `${model.provider}/${model.id}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function resolveModelIdentifier(registry: ModelRegistry, identifier: string): Model<Api> | undefined {
|
|
13
|
+
const slash = identifier.indexOf("/");
|
|
14
|
+
if (slash > 0) {
|
|
15
|
+
const provider = identifier.slice(0, slash);
|
|
16
|
+
const id = identifier.slice(slash + 1);
|
|
17
|
+
return registry.getAll().find((m) => m.provider === provider && m.id === id);
|
|
18
|
+
}
|
|
19
|
+
// No provider prefix: search by exact id across all models.
|
|
20
|
+
return registry.getAll().find((m) => m.id === identifier);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Choose the executor model: configured first, else first non-current text authed model. */
|
|
24
|
+
export function resolveExecutorModel(
|
|
25
|
+
registry: ModelRegistry,
|
|
26
|
+
currentModel: Model<Api> | undefined,
|
|
27
|
+
configuredExecutor: string | undefined,
|
|
28
|
+
warnings: string[],
|
|
29
|
+
): Model<Api> | undefined {
|
|
30
|
+
if (configuredExecutor) {
|
|
31
|
+
const resolved = resolveModelIdentifier(registry, configuredExecutor);
|
|
32
|
+
if (resolved && resolved.input.includes("text") && registry.hasConfiguredAuth(resolved)) {
|
|
33
|
+
return resolved;
|
|
34
|
+
}
|
|
35
|
+
warnings.push(
|
|
36
|
+
resolved && registry.hasConfiguredAuth(resolved)
|
|
37
|
+
? `Configured executor ${configuredExecutor} is not a text-capable model; falling back to auto-selection.`
|
|
38
|
+
: `Configured executor ${configuredExecutor} is not authed; falling back to auto-selection.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const available = registry.getAvailable().filter((m) => m.input.includes("text"));
|
|
43
|
+
if (available.length === 0) return undefined;
|
|
44
|
+
|
|
45
|
+
const nonCurrent = currentModel
|
|
46
|
+
? available.filter((m) => modelDisplay(m) !== modelDisplay(currentModel))
|
|
47
|
+
: available;
|
|
48
|
+
|
|
49
|
+
if (nonCurrent.length > 0) return nonCurrent[0];
|
|
50
|
+
|
|
51
|
+
warnings.push("Executor fell back to the current planner model; cost savings are not guaranteed.");
|
|
52
|
+
return available[0];
|
|
53
|
+
}
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System prompts for pi-devin-fusion.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const PLANNER_FORCE_PROMPT_PREFIX = `You are the PLANNER/REVIEWER in a Devin-fusion style setup (pi-devin-fusion). You own the plan, the interpretation of ambiguity, and the final review. The SIDEKICK (a separate, cheaper executor model) owns mechanical implementation.
|
|
6
|
+
|
|
7
|
+
Rules:
|
|
8
|
+
- Delegate implementation and codebase exploration for code-changing work to the sidekick tool with a PRECISE spec: exact files, exact changes, constraints to preserve. Do not give vague goals.
|
|
9
|
+
- Do NOT call native mutating tools (bash/edit/write) yourself for the implementation; the sidekick performs the edits. You may still read files and inspect diffs for review.
|
|
10
|
+
- When the sidekick returns a result, review it against real command output and the plan before giving your final answer. If it is wrong, send corrected feedback through the sidekick tool rather than editing yourself.
|
|
11
|
+
- For ambiguous intent or design choices, make the decision yourself, then hand the sidekick an unambiguous spec. Do not ask the sidekick to make the judgment call.
|
|
12
|
+
- ASCII-only output.`;
|
|
13
|
+
|
|
14
|
+
export const SIDEKICK_SYSTEM_PROMPT = `You are the SIDEKICK executor in a Devin-fusion style setup (pi-devin-fusion). The planner model owns the plan and final review; you own execution.
|
|
15
|
+
|
|
16
|
+
Operating rules:
|
|
17
|
+
- Execute the exact spec you are given. Do not redesign, rename beyond the spec, or touch files you were not asked to touch.
|
|
18
|
+
- Produce complete, unabridged changes. No placeholders, no "// rest unchanged", no elided blocks.
|
|
19
|
+
- Run verification yourself when asked (build / test / lint) and report the real command output, not a summary of what you expect to happen.
|
|
20
|
+
- Read only the files you need to do the work; do not pull in the whole repository.
|
|
21
|
+
- If the task turns out to need judgment (ambiguous intent, a design choice, a spec that contradicts itself), STOP and return exactly: NEEDS_DECISION: <the specific question to escalate>. Do not guess on judgment calls.
|
|
22
|
+
- Return a concise result: what you changed (files + one line each), the verification you ran and its outcome, and anything the planner should review. No preamble, no self-congratulation.
|
|
23
|
+
- ASCII-only output.`;
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executor tool resolution for pi-devin-fusion.
|
|
3
|
+
*
|
|
4
|
+
* Tool definitions are built from a hard-coded allowlist of pi's own tool
|
|
5
|
+
* factories — never from the live extension registry — so the `sidekick` tool
|
|
6
|
+
* can never leak into the executor's tool list (recursion guarantee).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
createBashToolDefinition,
|
|
11
|
+
createEditToolDefinition,
|
|
12
|
+
createFindToolDefinition,
|
|
13
|
+
createGrepToolDefinition,
|
|
14
|
+
createLsToolDefinition,
|
|
15
|
+
createReadToolDefinition,
|
|
16
|
+
createWriteToolDefinition,
|
|
17
|
+
} from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import type { TSchema } from "typebox";
|
|
19
|
+
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
|
20
|
+
import { DEFAULT_MAX_TOOL_CALLS, MAX_TOOL_CALLS, MIN_TOOL_CALLS } from "./config.ts";
|
|
21
|
+
import type { ToolSelection } from "./types.ts";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Subset of ToolDefinition used by the executor tool loop in llm.ts.
|
|
25
|
+
* Intentionally structural and minimal — the factory return types are
|
|
26
|
+
* heterogeneous TypeBox schemas we don't need to unify at the type level.
|
|
27
|
+
*/
|
|
28
|
+
export interface ExecutorToolDef {
|
|
29
|
+
name: string;
|
|
30
|
+
description: string;
|
|
31
|
+
parameters: TSchema;
|
|
32
|
+
execute(
|
|
33
|
+
toolCallId: string,
|
|
34
|
+
args: Record<string, unknown>,
|
|
35
|
+
signal: AbortSignal | undefined,
|
|
36
|
+
extra: unknown,
|
|
37
|
+
ctx: unknown,
|
|
38
|
+
): Promise<{ content: ToolResultMessage["content"]; isError: boolean }>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const READONLY_TOOL_NAMES = ["read", "grep", "find", "ls"] as const;
|
|
42
|
+
export const MUTATING_TOOL_NAMES = ["bash", "edit", "write"] as const;
|
|
43
|
+
export const ALL_TOOL_NAMES = [...READONLY_TOOL_NAMES, ...MUTATING_TOOL_NAMES] as const;
|
|
44
|
+
|
|
45
|
+
type ToolName = (typeof ALL_TOOL_NAMES)[number];
|
|
46
|
+
|
|
47
|
+
// Library boundary: the factory return types carry `any` TState from the
|
|
48
|
+
// coding-agent. We narrow to our structural ExecutorToolDef with `unknown`
|
|
49
|
+
// to keep source-level `any` out of this module.
|
|
50
|
+
function build(name: ToolName, cwd: string): ExecutorToolDef {
|
|
51
|
+
switch (name) {
|
|
52
|
+
case "read": return createReadToolDefinition(cwd) as unknown as ExecutorToolDef;
|
|
53
|
+
case "grep": return createGrepToolDefinition(cwd) as unknown as ExecutorToolDef;
|
|
54
|
+
case "find": return createFindToolDefinition(cwd) as unknown as ExecutorToolDef;
|
|
55
|
+
case "ls": return createLsToolDefinition(cwd) as unknown as ExecutorToolDef;
|
|
56
|
+
case "bash": return createBashToolDefinition(cwd) as unknown as ExecutorToolDef;
|
|
57
|
+
case "edit": return createEditToolDefinition(cwd) as unknown as ExecutorToolDef;
|
|
58
|
+
case "write": return createWriteToolDefinition(cwd) as unknown as ExecutorToolDef;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isToolName(value: string): value is ToolName {
|
|
63
|
+
return (ALL_TOOL_NAMES as readonly string[]).includes(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Normalize a selection (mode or explicit list) into an ordered, deduped tool-name list. */
|
|
67
|
+
export function selectionToNames(selection: ToolSelection | undefined): ToolName[] {
|
|
68
|
+
if (!selection || selection === "none") return [];
|
|
69
|
+
if (selection === "readonly") return [...READONLY_TOOL_NAMES];
|
|
70
|
+
if (selection === "all") return [...ALL_TOOL_NAMES];
|
|
71
|
+
if (Array.isArray(selection)) {
|
|
72
|
+
const seen = new Set<string>();
|
|
73
|
+
const out: ToolName[] = [];
|
|
74
|
+
for (const raw of selection) {
|
|
75
|
+
const name = String(raw).toLowerCase();
|
|
76
|
+
if (isToolName(name) && !seen.has(name)) {
|
|
77
|
+
seen.add(name);
|
|
78
|
+
out.push(name);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Build executable tool definitions for the resolved selection. */
|
|
87
|
+
export function resolveToolDefs(selection: ToolSelection | undefined, cwd: string): ExecutorToolDef[] {
|
|
88
|
+
return selectionToNames(selection).map((n) => build(n, cwd));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** True when the selection includes a tool that can mutate the filesystem or run commands. */
|
|
92
|
+
export function isMutatingSelection(selection: ToolSelection | undefined): boolean {
|
|
93
|
+
return selectionToNames(selection).some((n) => (MUTATING_TOOL_NAMES as readonly string[]).includes(n));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A short, stable label for a selection (for footers, status, diagnostics). */
|
|
97
|
+
export function selectionLabel(selection: ToolSelection | undefined): string {
|
|
98
|
+
if (!selection || selection === "none") return "none";
|
|
99
|
+
if (selection === "readonly" || selection === "all") return selection;
|
|
100
|
+
const names = selectionToNames(selection);
|
|
101
|
+
return names.length ? names.join(",") : "none";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Clamp a max-tool-calls value into the supported range, defaulting when absent. */
|
|
105
|
+
export function clampMaxToolCalls(value: number | undefined): number {
|
|
106
|
+
if (value === undefined || !Number.isFinite(value)) return DEFAULT_MAX_TOOL_CALLS;
|
|
107
|
+
return Math.max(MIN_TOOL_CALLS, Math.min(MAX_TOOL_CALLS, Math.floor(value)));
|
|
108
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for pi-devin-fusion.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
6
|
+
|
|
7
|
+
export type { Api, Model };
|
|
8
|
+
|
|
9
|
+
/** Named executor tool bundles, or an explicit list of tool names. */
|
|
10
|
+
export type ToolMode = "none" | "readonly" | "all";
|
|
11
|
+
export type ToolSelection = ToolMode | string[];
|
|
12
|
+
export type FooterDisplay = "off" | "compact" | "full";
|
|
13
|
+
export type DevinMode = "available" | "forced" | "off";
|
|
14
|
+
|
|
15
|
+
export interface DevinConfig {
|
|
16
|
+
/** Explicit executor model identifier, e.g. "anthropic/claude-haiku-4-5". */
|
|
17
|
+
executor?: string;
|
|
18
|
+
/** Max tokens for the executor's final answer. */
|
|
19
|
+
maxExecutorOutputTokens?: number;
|
|
20
|
+
/** Sampling temperature for the executor. */
|
|
21
|
+
temperature?: number;
|
|
22
|
+
/** Executor tool access: "none", "readonly" (read/grep/find/ls), or "all" (adds bash/edit/write). */
|
|
23
|
+
executorTools?: ToolSelection;
|
|
24
|
+
/** Max tool-call steps the executor may take (1–100). */
|
|
25
|
+
maxToolCalls?: number;
|
|
26
|
+
/** Non-interactive consent for mutating executor tools (bash/edit/write). */
|
|
27
|
+
executorToolsConsent?: boolean;
|
|
28
|
+
/** Footer verbosity: "full" (default), "compact", or "off". */
|
|
29
|
+
footerDisplay?: FooterDisplay;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A DevinConfig after `applyDefaults`: the numeric/tool knobs are guaranteed present. */
|
|
33
|
+
export type ResolvedDevinConfig = DevinConfig & {
|
|
34
|
+
maxExecutorOutputTokens: number;
|
|
35
|
+
temperature: number;
|
|
36
|
+
executorTools: ToolSelection;
|
|
37
|
+
maxToolCalls: number;
|
|
38
|
+
footerDisplay: FooterDisplay;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Options accepted from a session/extension override (planner cannot override tools). */
|
|
42
|
+
export interface SidekickOptions {
|
|
43
|
+
executor?: string;
|
|
44
|
+
executorTools?: ToolSelection;
|
|
45
|
+
maxToolCalls?: number;
|
|
46
|
+
footerDisplay?: FooterDisplay;
|
|
47
|
+
context_text?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SidekickResult {
|
|
51
|
+
content: Array<{ type: "text"; text: string }>;
|
|
52
|
+
details: SidekickDetails;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SidekickDetails {
|
|
56
|
+
status: "ok" | "error";
|
|
57
|
+
executor_model?: string;
|
|
58
|
+
turns?: number;
|
|
59
|
+
tool_calls?: Array<{ name: string; ok: boolean }>;
|
|
60
|
+
capped?: boolean;
|
|
61
|
+
warnings?: string[];
|
|
62
|
+
output?: string;
|
|
63
|
+
error?: string;
|
|
64
|
+
failure_reason?:
|
|
65
|
+
| "mutation_consent_required"
|
|
66
|
+
| "no_executor_model"
|
|
67
|
+
| "rate_limited"
|
|
68
|
+
| "insufficient_credits"
|
|
69
|
+
| "unexpected_error";
|
|
70
|
+
}
|