@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.
@@ -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(/&nbsp;/g, " ")
34
+ .replace(/&amp;/g, "&")
35
+ .replace(/&lt;/g, "<")
36
+ .replace(/&gt;/g, ">")
37
+ .replace(/&#39;|&apos;/g, "'")
38
+ .replace(/&quot;/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
  };
@@ -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
+ }