@quintinshaw/pi-dynamic-workflows 1.4.0 → 1.6.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 +14 -1
- package/dist/adversarial-review.d.ts +7 -2
- package/dist/adversarial-review.js +46 -38
- package/dist/agent.d.ts +2 -0
- package/dist/agent.js +6 -2
- package/dist/builtin-commands.d.ts +8 -0
- package/dist/builtin-commands.js +77 -0
- package/dist/deep-research.d.ts +10 -10
- package/dist/deep-research.js +45 -45
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -0
- package/dist/web-tools.d.ts +15 -0
- package/dist/web-tools.js +119 -0
- package/dist/workflow.d.ts +1 -0
- package/dist/workflow.js +18 -2
- package/dist/worktree.d.ts +25 -0
- package/dist/worktree.js +61 -0
- package/extensions/workflow.ts +8 -1
- package/package.json +1 -1
- package/src/adversarial-review.ts +46 -43
- package/src/agent.ts +8 -2
- package/src/builtin-commands.ts +77 -0
- package/src/deep-research.ts +51 -59
- package/src/index.ts +5 -0
- package/src/web-tools.ts +123 -0
- package/src/workflow.ts +17 -3
- package/src/worktree.ts +76 -0
package/src/web-tools.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real web tools for research workflows. These execute in the extension host
|
|
3
|
+
* process (which has network access), not in a subagent sandbox, so they perform
|
|
4
|
+
* genuine HTTP requests via Node's fetch.
|
|
5
|
+
*
|
|
6
|
+
* - web_search: best-effort Bing HTML scrape -> result {url, title}
|
|
7
|
+
* - web_fetch: fetch a URL and return readable text (HTML stripped, truncated)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
|
|
13
|
+
const UA =
|
|
14
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36";
|
|
15
|
+
|
|
16
|
+
async function fetchText(url: string, timeoutMs = 15000): Promise<{ status: number; body: string }> {
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(url, { headers: { "user-agent": UA }, signal: controller.signal, redirect: "follow" });
|
|
21
|
+
return { status: res.status, body: await res.text() };
|
|
22
|
+
} finally {
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function htmlToText(html: string): string {
|
|
28
|
+
return html
|
|
29
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
30
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
31
|
+
.replace(/<\/(p|div|li|h[1-6]|tr|br)>/gi, "\n")
|
|
32
|
+
.replace(/<[^>]+>/g, " ")
|
|
33
|
+
.replace(/ /g, " ")
|
|
34
|
+
.replace(/&/g, "&")
|
|
35
|
+
.replace(/</g, "<")
|
|
36
|
+
.replace(/>/g, ">")
|
|
37
|
+
.replace(/'|'/g, "'")
|
|
38
|
+
.replace(/"/g, '"')
|
|
39
|
+
.replace(/[ \t]+/g, " ")
|
|
40
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
41
|
+
.trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseBingResults(html: string, limit: number): Array<{ url: string; title: string }> {
|
|
45
|
+
const out: Array<{ url: string; title: string }> = [];
|
|
46
|
+
const seen = new Set<string>();
|
|
47
|
+
for (const m of html.matchAll(/<h2[^>]*>\s*<a[^>]+href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/g)) {
|
|
48
|
+
const url = m[1];
|
|
49
|
+
if (/\.bing\.com|go\.microsoft\.com/.test(url) || seen.has(url)) continue;
|
|
50
|
+
seen.add(url);
|
|
51
|
+
out.push({ url, title: m[2].replace(/<[^>]+>/g, "").trim() });
|
|
52
|
+
if (out.length >= limit) break;
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A tool that searches the web (best-effort) and returns result URLs + titles. */
|
|
58
|
+
export function createWebSearchTool(): ToolDefinition {
|
|
59
|
+
return defineTool({
|
|
60
|
+
name: "web_search",
|
|
61
|
+
label: "Web Search",
|
|
62
|
+
description: "Search the web and return a list of result URLs and titles. Use before web_fetch to find sources.",
|
|
63
|
+
promptSnippet: "Search the web for sources",
|
|
64
|
+
parameters: Type.Object({
|
|
65
|
+
query: Type.String({ description: "The search query." }),
|
|
66
|
+
count: Type.Optional(Type.Number({ description: "Max results (default 6)." })),
|
|
67
|
+
}),
|
|
68
|
+
async execute(_id, params: { query: string; count?: number }) {
|
|
69
|
+
const limit = Math.min(Math.max(params.count ?? 6, 1), 10);
|
|
70
|
+
try {
|
|
71
|
+
const { status, body } = await fetchText(`https://www.bing.com/search?q=${encodeURIComponent(params.query)}`);
|
|
72
|
+
const results = parseBingResults(body, limit);
|
|
73
|
+
const text = results.length
|
|
74
|
+
? results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}`).join("\n")
|
|
75
|
+
: `No results parsed (HTTP ${status}). Try a different query or fetch a known URL directly.`;
|
|
76
|
+
return { content: [{ type: "text", text }], details: { results } };
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return {
|
|
79
|
+
content: [{ type: "text", text: `web_search failed: ${error instanceof Error ? error.message : error}` }],
|
|
80
|
+
details: { results: [] as Array<{ url: string; title: string }> },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
}) as unknown as ToolDefinition;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A tool that fetches a URL and returns readable text. */
|
|
88
|
+
export function createWebFetchTool(maxChars = 6000): ToolDefinition {
|
|
89
|
+
return defineTool({
|
|
90
|
+
name: "web_fetch",
|
|
91
|
+
label: "Web Fetch",
|
|
92
|
+
description: "Fetch a URL and return its readable text content (HTML stripped, truncated).",
|
|
93
|
+
promptSnippet: "Fetch a URL's text",
|
|
94
|
+
parameters: Type.Object({
|
|
95
|
+
url: Type.String({ description: "The absolute URL to fetch." }),
|
|
96
|
+
}),
|
|
97
|
+
async execute(_id, params: { url: string }) {
|
|
98
|
+
try {
|
|
99
|
+
const { status, body } = await fetchText(params.url);
|
|
100
|
+
const text = htmlToText(body).slice(0, maxChars);
|
|
101
|
+
return {
|
|
102
|
+
content: [{ type: "text", text: `HTTP ${status} ${params.url}\n\n${text}` }],
|
|
103
|
+
details: { status, url: params.url },
|
|
104
|
+
};
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return {
|
|
107
|
+
content: [
|
|
108
|
+
{
|
|
109
|
+
type: "text",
|
|
110
|
+
text: `web_fetch failed for ${params.url}: ${error instanceof Error ? error.message : error}`,
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
details: { status: 0, url: params.url },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
}) as unknown as ToolDefinition;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Both web tools, for injecting into a research workflow's agents. */
|
|
121
|
+
export function createWebTools(): ToolDefinition[] {
|
|
122
|
+
return [createWebSearchTool(), createWebFetchTool()];
|
|
123
|
+
}
|
package/src/workflow.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { DEFAULT_AGENT_TIMEOUT_MS, MAX_AGENTS_PER_RUN, MAX_CONCURRENCY } from ".
|
|
|
9
9
|
import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
|
|
10
10
|
import { createWorkflowLogger } from "./logger.js";
|
|
11
11
|
import { parseModelRoutingFromMeta, resolveModelForPhase } from "./model-routing.js";
|
|
12
|
+
import { createWorktree, removeWorktree, type Worktree } from "./worktree.js";
|
|
12
13
|
|
|
13
14
|
export interface WorkflowMetaPhase {
|
|
14
15
|
title: string;
|
|
@@ -54,7 +55,7 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
|
|
|
54
55
|
onLog?: (message: string) => void;
|
|
55
56
|
onPhase?: (title: string) => void;
|
|
56
57
|
onAgentStart?: (event: { label: string; phase?: string; prompt: string; model?: string }) => void;
|
|
57
|
-
onAgentEnd?: (event: { label: string; phase?: string; result: unknown; tokens?: number }) => void;
|
|
58
|
+
onAgentEnd?: (event: { label: string; phase?: string; result: unknown; tokens?: number; worktree?: string }) => void;
|
|
58
59
|
onTokenUsage?: (usage: { input: number; output: number; total: number; cost: number }) => void;
|
|
59
60
|
}
|
|
60
61
|
|
|
@@ -116,6 +117,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
116
117
|
const maxAgents = options.maxAgents ?? MAX_AGENTS_PER_RUN;
|
|
117
118
|
const agentTimeoutMs = options.agentTimeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS;
|
|
118
119
|
const runId = options.runId ?? `run-${started.toString(36)}`;
|
|
120
|
+
const baseCwd = options.cwd ?? process.cwd();
|
|
119
121
|
|
|
120
122
|
// Initialize logger
|
|
121
123
|
const logger = createWorkflowLogger({
|
|
@@ -211,6 +213,14 @@ export async function runWorkflow<T = unknown>(
|
|
|
211
213
|
|
|
212
214
|
options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
|
|
213
215
|
|
|
216
|
+
// Optional per-agent worktree isolation (deterministic name -> stable resume keys).
|
|
217
|
+
let worktree: Worktree | undefined;
|
|
218
|
+
if (agentOptions.isolation === "worktree") {
|
|
219
|
+
worktree = await createWorktree(baseCwd, `${runId}-${callIndex}-${label}`);
|
|
220
|
+
if (!worktree.isolated) log(`isolation ignored for "${label}" (${worktree.reason})`);
|
|
221
|
+
}
|
|
222
|
+
const runCwd = worktree?.isolated ? worktree.cwd : undefined;
|
|
223
|
+
|
|
214
224
|
// Captured from the subagent's real session usage; falls back to an
|
|
215
225
|
// estimate when the provider reports no usage (total === 0).
|
|
216
226
|
let usage: AgentUsage | undefined;
|
|
@@ -237,6 +247,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
237
247
|
signal: options.signal,
|
|
238
248
|
instructions: buildAgentInstructions(assignedPhase, agentOptions),
|
|
239
249
|
model: modelSpec,
|
|
250
|
+
cwd: runCwd,
|
|
240
251
|
onUsage: (u: AgentUsage) => {
|
|
241
252
|
usage = u;
|
|
242
253
|
},
|
|
@@ -249,7 +260,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
249
260
|
|
|
250
261
|
const tokens = recordTokens(result);
|
|
251
262
|
options.onAgentJournal?.({ index: callIndex, hash: callHash, result });
|
|
252
|
-
options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens });
|
|
263
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens, worktree: runCwd });
|
|
253
264
|
return result;
|
|
254
265
|
} catch (error) {
|
|
255
266
|
if (options.signal?.aborted) throw error;
|
|
@@ -257,13 +268,16 @@ export async function runWorkflow<T = unknown>(
|
|
|
257
268
|
const workflowError = wrapError(error, { agentLabel: label });
|
|
258
269
|
logger.error(`agent ${label} failed: ${workflowError.message}`);
|
|
259
270
|
const tokens = recordTokens(null);
|
|
260
|
-
options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens });
|
|
271
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens, worktree: runCwd });
|
|
261
272
|
|
|
262
273
|
// Return null for recoverable errors
|
|
263
274
|
if (workflowError.recoverable) {
|
|
264
275
|
return null;
|
|
265
276
|
}
|
|
266
277
|
throw workflowError;
|
|
278
|
+
} finally {
|
|
279
|
+
// Always tear down the worktree, even on timeout/abort.
|
|
280
|
+
if (worktree?.isolated) await removeWorktree(worktree);
|
|
267
281
|
}
|
|
268
282
|
});
|
|
269
283
|
};
|
package/src/worktree.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent git worktree isolation. When an agent requests `isolation: "worktree"`,
|
|
3
|
+
* it runs in a throwaway worktree on its own branch so parallel agents can edit the
|
|
4
|
+
* same files without conflict. Results are NOT auto-merged — the path is surfaced for
|
|
5
|
+
* the caller to inspect. Falls back to a logged no-op when isolation isn't possible.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
|
|
12
|
+
const exec = promisify(execFile);
|
|
13
|
+
|
|
14
|
+
export interface Worktree {
|
|
15
|
+
/** True when a real worktree was created; false means "ran in the shared tree". */
|
|
16
|
+
isolated: boolean;
|
|
17
|
+
/** cwd the agent should run in (worktree path when isolated, else the base cwd). */
|
|
18
|
+
cwd: string;
|
|
19
|
+
branch?: string;
|
|
20
|
+
/** Repo root the worktree was added to (for teardown). */
|
|
21
|
+
repoRoot?: string;
|
|
22
|
+
/** Why isolation was skipped, when isolated === false. */
|
|
23
|
+
reason?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function slug(name: string): string {
|
|
27
|
+
return (
|
|
28
|
+
name
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
31
|
+
.replace(/^-+|-+$/g, "")
|
|
32
|
+
.slice(0, 32) || "agent"
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create an isolated worktree under `<repoRoot>/.pi/worktrees/<name>` on branch
|
|
38
|
+
* `pi/wf/<name>`. The `name` must be deterministic (derived from runId + call index,
|
|
39
|
+
* never wall-clock) so resume keys stay stable. Returns a no-op Worktree on any failure.
|
|
40
|
+
*/
|
|
41
|
+
export async function createWorktree(baseCwd: string, name: string): Promise<Worktree> {
|
|
42
|
+
const id = slug(name);
|
|
43
|
+
let repoRoot: string;
|
|
44
|
+
try {
|
|
45
|
+
const { stdout } = await exec("git", ["-C", baseCwd, "rev-parse", "--show-toplevel"]);
|
|
46
|
+
repoRoot = stdout.trim();
|
|
47
|
+
} catch {
|
|
48
|
+
return { isolated: false, cwd: baseCwd, reason: "not a git repository" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const path = join(repoRoot, ".pi", "worktrees", id);
|
|
52
|
+
const branch = `pi/wf/${id}`;
|
|
53
|
+
try {
|
|
54
|
+
await exec("git", ["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
|
|
55
|
+
return { isolated: true, cwd: path, branch, repoRoot };
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return { isolated: false, cwd: baseCwd, reason: error instanceof Error ? error.message : String(error) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Remove a worktree and its branch. Best-effort; safe to call on a no-op Worktree. */
|
|
62
|
+
export async function removeWorktree(wt: Worktree): Promise<void> {
|
|
63
|
+
if (!wt.isolated || !wt.repoRoot) return;
|
|
64
|
+
try {
|
|
65
|
+
await exec("git", ["-C", wt.repoRoot, "worktree", "remove", "--force", wt.cwd]);
|
|
66
|
+
} catch {
|
|
67
|
+
// already gone / locked — fall through
|
|
68
|
+
}
|
|
69
|
+
if (wt.branch) {
|
|
70
|
+
try {
|
|
71
|
+
await exec("git", ["-C", wt.repoRoot, "branch", "-D", wt.branch]);
|
|
72
|
+
} catch {
|
|
73
|
+
// branch already deleted
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|