@shanepadgett/tau-agent 0.7.2 → 0.8.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/extensions/appshot/index.ts +3 -13
- package/extensions/context/definitions.ts +39 -0
- package/extensions/context/index.ts +35 -8
- package/extensions/context/sync.ts +45 -19
- package/extensions/image-gen/index.ts +1 -8
- package/extensions/silent-command-runner/index.ts +8 -11
- package/extensions/soul/README.md +1 -1
- package/extensions/soul/index.ts +85 -4
- package/extensions/soul/prompt.ts +36 -7
- package/extensions/subagent/index.ts +15 -15
- package/extensions/tau-help/help.md +5 -1
- package/extensions/tool-loader/README.md +13 -0
- package/extensions/tool-loader/index.ts +134 -0
- package/extensions/turn-budget/index.ts +10 -7
- package/extensions/web/codesearch.ts +1 -7
- package/extensions/web/webfetch.ts +1 -7
- package/extensions/web/websearch.ts +1 -7
- package/package.json +2 -2
- package/shared/model-fallback/index.ts +7 -4
- package/shared/system-prompt-contributions.ts +40 -0
|
@@ -116,11 +116,7 @@ function registerAppshotTools(pi: ExtensionAPI, runHelper: RunHelper): void {
|
|
|
116
116
|
name: "list_windows",
|
|
117
117
|
label: "List Windows",
|
|
118
118
|
description:
|
|
119
|
-
"List visible normal macOS windows as compact TOON with window IDs, titles, application identity, process IDs, and bounds. Requires macOS 14 or newer and Screen & System Audio Recording permission.",
|
|
120
|
-
promptSnippet: "Discover visible macOS application windows and their IDs",
|
|
121
|
-
promptGuidelines: [
|
|
122
|
-
"Use list_windows to discover graphical applications and select an exact window before calling screenshot_window.",
|
|
123
|
-
],
|
|
119
|
+
"List visible normal macOS windows as compact TOON with window IDs, titles, application identity, process IDs, and bounds. Use list_windows to discover exact window IDs and application PIDs before screenshot_window or activate_app. Requires macOS 14 or newer and Screen & System Audio Recording permission.",
|
|
124
120
|
parameters: listWindowsSchema,
|
|
125
121
|
async execute(_toolCallId, _params, signal) {
|
|
126
122
|
if (process.platform !== "darwin") throw new Error("list_windows is only available on macOS");
|
|
@@ -158,9 +154,7 @@ function registerAppshotTools(pi: ExtensionAPI, runHelper: RunHelper): void {
|
|
|
158
154
|
name: "screenshot_window",
|
|
159
155
|
label: "Screenshot Window",
|
|
160
156
|
description:
|
|
161
|
-
"Capture one visible macOS window by
|
|
162
|
-
promptSnippet: "Capture and inspect an exact visible macOS window by window ID",
|
|
163
|
-
promptGuidelines: ["Call list_windows before screenshot_window; pass its exact window_id and a PNG path."],
|
|
157
|
+
"Capture one visible macOS window by an exact ID returned by list_windows, resize it to fit within 1568×1568 pixels, save it to the required PNG path, and inspect the image. Call list_windows first.",
|
|
164
158
|
parameters: screenshotWindowSchema,
|
|
165
159
|
async execute(_toolCallId, params: ScreenshotWindowParams, signal, onUpdate, ctx) {
|
|
166
160
|
if (process.platform !== "darwin") throw new Error("screenshot_window is only available on macOS");
|
|
@@ -219,11 +213,7 @@ function registerAppshotTools(pi: ExtensionAPI, runHelper: RunHelper): void {
|
|
|
219
213
|
name: "activate_app",
|
|
220
214
|
label: "Activate App",
|
|
221
215
|
description:
|
|
222
|
-
"Bring a running macOS application and its windows to the foreground by process ID. Use
|
|
223
|
-
promptSnippet: "Bring a listed macOS application to the foreground by process ID",
|
|
224
|
-
promptGuidelines: [
|
|
225
|
-
"Use activate_app only when foregrounding a listed application is needed for visual validation because it changes user focus.",
|
|
226
|
-
],
|
|
216
|
+
"Bring a running macOS application and its windows to the foreground by a process ID returned by list_windows. Use only when foregrounding is required for visual validation because activate_app changes user focus.",
|
|
227
217
|
parameters: activateAppSchema,
|
|
228
218
|
async execute(_toolCallId, params, signal) {
|
|
229
219
|
if (process.platform !== "darwin") throw new Error("activate_app is only available on macOS");
|
|
@@ -15,6 +15,45 @@ export interface ContextEntry {
|
|
|
15
15
|
path: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
const CONTEXT_IGNORED_FILENAMES = new Set([
|
|
19
|
+
"bun.lock",
|
|
20
|
+
"bun.lockb",
|
|
21
|
+
"Cargo.lock",
|
|
22
|
+
"composer.lock",
|
|
23
|
+
"flake.lock",
|
|
24
|
+
"Gemfile.lock",
|
|
25
|
+
"go.sum",
|
|
26
|
+
"mix.lock",
|
|
27
|
+
"npm-shrinkwrap.json",
|
|
28
|
+
"package-lock.json",
|
|
29
|
+
"Package.resolved",
|
|
30
|
+
"Pipfile.lock",
|
|
31
|
+
"pnpm-lock.yaml",
|
|
32
|
+
"Podfile.lock",
|
|
33
|
+
"poetry.lock",
|
|
34
|
+
"pubspec.lock",
|
|
35
|
+
"uv.lock",
|
|
36
|
+
"yarn.lock",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
export function isContextEligiblePath(path: string): boolean {
|
|
40
|
+
return (
|
|
41
|
+
path !== "LICENSE" &&
|
|
42
|
+
!CONTEXT_IGNORED_FILENAMES.has(basename(path)) &&
|
|
43
|
+
path !== ".pi/tau/ideas.jsonl" &&
|
|
44
|
+
path !== ".working" &&
|
|
45
|
+
!path.startsWith(".working/") &&
|
|
46
|
+
path !== ".pi/contexts" &&
|
|
47
|
+
!path.startsWith(".pi/contexts/")
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isSensitiveContextPath(path: string): boolean {
|
|
52
|
+
const name = basename(path);
|
|
53
|
+
if (name === ".env.example" || name === ".env.sample") return false;
|
|
54
|
+
return name === ".env" || name.startsWith(".env.") || /\.(?:pem|key|crt|p12|pfx)$/i.test(name);
|
|
55
|
+
}
|
|
56
|
+
|
|
18
57
|
export async function pathExists(path: string): Promise<boolean> {
|
|
19
58
|
try {
|
|
20
59
|
await access(path);
|
|
@@ -5,6 +5,7 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
5
5
|
import { Type } from "typebox";
|
|
6
6
|
import { emitTauEvent } from "../../shared/events.ts";
|
|
7
7
|
import { createInjectedContext } from "../../shared/injected-context.ts";
|
|
8
|
+
import { registerTauSystemPromptContribution } from "../../shared/system-prompt-contributions.ts";
|
|
8
9
|
import { ContextPanel } from "./panel.ts";
|
|
9
10
|
import { findProjectRoot, loadContextEntries, type ContextEntry } from "./definitions.ts";
|
|
10
11
|
import { runContextSync, type ContextSyncDetails } from "./sync.ts";
|
|
@@ -22,6 +23,15 @@ function compactResult(details: ContextSyncDetails) {
|
|
|
22
23
|
|
|
23
24
|
export default function contextExtension(pi: ExtensionAPI): void {
|
|
24
25
|
let active: ContextEntry[] = [];
|
|
26
|
+
const activeToolExecutions = new Map<string, { toolName: string; done: Promise<void>; complete: () => void }>();
|
|
27
|
+
const unregisterPrompt = registerTauSystemPromptContribution({
|
|
28
|
+
id: "context.selected-authority",
|
|
29
|
+
order: 100,
|
|
30
|
+
render: () =>
|
|
31
|
+
active.length
|
|
32
|
+
? "Treat the autoread files as the authoritative project context and current snapshots. Do not reread them or search for coverage around them. Start work from them immediately. Explore outside them only when the user's request or concrete evidence in those files requires missing code or information."
|
|
33
|
+
: undefined,
|
|
34
|
+
});
|
|
25
35
|
|
|
26
36
|
pi.registerCommand("context", {
|
|
27
37
|
description: "Select repository context entries and inject their files",
|
|
@@ -94,7 +104,12 @@ export default function contextExtension(pi: ExtensionAPI): void {
|
|
|
94
104
|
label: "context_sync",
|
|
95
105
|
description: "Synchronize repository context from current Git changes.",
|
|
96
106
|
parameters: contextSyncParams,
|
|
97
|
-
async execute(
|
|
107
|
+
async execute(id, _params, _signal, onUpdate, ctx) {
|
|
108
|
+
await Promise.all(
|
|
109
|
+
[...activeToolExecutions]
|
|
110
|
+
.filter(([toolCallId, execution]) => toolCallId !== id && execution.toolName !== "context_sync")
|
|
111
|
+
.map(([, execution]) => execution.done),
|
|
112
|
+
);
|
|
98
113
|
return compactResult(
|
|
99
114
|
await runContextSync(pi, ctx, (status) =>
|
|
100
115
|
onUpdate?.({ content: [{ type: "text", text: status }], details: undefined }),
|
|
@@ -131,6 +146,20 @@ export default function contextExtension(pi: ExtensionAPI): void {
|
|
|
131
146
|
}),
|
|
132
147
|
);
|
|
133
148
|
|
|
149
|
+
pi.on("tool_execution_start", (event) => {
|
|
150
|
+
let complete = () => {};
|
|
151
|
+
const done = new Promise<void>((resolve) => {
|
|
152
|
+
complete = resolve;
|
|
153
|
+
});
|
|
154
|
+
activeToolExecutions.set(event.toolCallId, { toolName: event.toolName, done, complete });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
pi.on("tool_execution_end", (event) => {
|
|
158
|
+
const execution = activeToolExecutions.get(event.toolCallId);
|
|
159
|
+
activeToolExecutions.delete(event.toolCallId);
|
|
160
|
+
execution?.complete();
|
|
161
|
+
});
|
|
162
|
+
|
|
134
163
|
pi.on("session_start", async (_event, ctx) => {
|
|
135
164
|
const root = await findProjectRoot(ctx.cwd);
|
|
136
165
|
const entries = await loadContextEntries(root);
|
|
@@ -146,11 +175,9 @@ export default function contextExtension(pi: ExtensionAPI): void {
|
|
|
146
175
|
: [];
|
|
147
176
|
active = entries.filter((entry) => ids.includes(entry.id));
|
|
148
177
|
});
|
|
149
|
-
pi.on("
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
: undefined,
|
|
155
|
-
);
|
|
178
|
+
pi.on("session_shutdown", () => {
|
|
179
|
+
unregisterPrompt();
|
|
180
|
+
for (const execution of activeToolExecutions.values()) execution.complete();
|
|
181
|
+
activeToolExecutions.clear();
|
|
182
|
+
});
|
|
156
183
|
}
|
|
@@ -9,6 +9,8 @@ import { generateToolValidated, resolveCandidates } from "../../shared/model-fal
|
|
|
9
9
|
import { truncAt } from "../../shared/text.ts";
|
|
10
10
|
import { XAI_CHAT_MODEL, XAI_PROVIDER } from "../xai/constants.ts";
|
|
11
11
|
import {
|
|
12
|
+
isContextEligiblePath,
|
|
13
|
+
isSensitiveContextPath,
|
|
12
14
|
loadContextEntries,
|
|
13
15
|
normalizeProjectPath,
|
|
14
16
|
pathExists,
|
|
@@ -130,6 +132,14 @@ export async function runContextSync(
|
|
|
130
132
|
pi: ExtensionAPI,
|
|
131
133
|
ctx: ExtensionContext,
|
|
132
134
|
onStatus?: (status: string) => void | Promise<void>,
|
|
135
|
+
): Promise<ContextSyncDetails> {
|
|
136
|
+
return withSyncLock(() => runContextSyncLocked(pi, ctx, onStatus));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function runContextSyncLocked(
|
|
140
|
+
pi: ExtensionAPI,
|
|
141
|
+
ctx: ExtensionContext,
|
|
142
|
+
onStatus?: (status: string) => void | Promise<void>,
|
|
133
143
|
): Promise<ContextSyncDetails> {
|
|
134
144
|
if (!ctx.isProjectTrusted()) throw new Error("Context sync requires a trusted project");
|
|
135
145
|
await onStatus?.("Inspecting repository context");
|
|
@@ -138,6 +148,7 @@ export async function runContextSync(
|
|
|
138
148
|
if (!status) throw new Error("No Git repository found");
|
|
139
149
|
if (status.fileCount === 0) return noChange("Existing context mappings already fit the changed scope.");
|
|
140
150
|
const evidence = await collectSyncEvidence(git, status.root);
|
|
151
|
+
if (evidence.files.length === 0) return noChange("Changed files are outside context catalog scope.");
|
|
141
152
|
const prompt = buildContextSyncPrompt(evidence);
|
|
142
153
|
const plan = await generateToolValidated(
|
|
143
154
|
ctx,
|
|
@@ -150,15 +161,13 @@ export async function runContextSync(
|
|
|
150
161
|
);
|
|
151
162
|
if (plan.outcome === "no-change") return noChange(plan.reason);
|
|
152
163
|
await onStatus?.("Applying context catalog changes");
|
|
153
|
-
return
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
throw new Error("Context catalog changed during context sync. Rerun context sync.");
|
|
161
|
-
});
|
|
164
|
+
return applyContextSyncPlan(evidence.root, plan, evidence.entries, async () => {
|
|
165
|
+
const currentEntries = await loadContextEntries(evidence.root);
|
|
166
|
+
const currentFiles = await collectDirtyFiles(git, evidence.root, currentEntries);
|
|
167
|
+
if ((await computeWorktreeSignature(git, evidence.root, currentFiles)) !== evidence.worktreeSignature)
|
|
168
|
+
throw new Error("Repository changed during context sync. Rerun context sync.");
|
|
169
|
+
if ((await computeCatalogSignature(evidence.root)) !== evidence.catalogSignature)
|
|
170
|
+
throw new Error("Context catalog changed during context sync. Rerun context sync.");
|
|
162
171
|
});
|
|
163
172
|
}
|
|
164
173
|
|
|
@@ -197,6 +206,7 @@ async function collectSyncEvidence(git: GitRunner, root: string): Promise<SyncEv
|
|
|
197
206
|
root,
|
|
198
207
|
files.filter((file) => dirtyExisting.has(file.path)),
|
|
199
208
|
);
|
|
209
|
+
for (const path of dependencies) if (!isContextEligiblePath(path)) dependencies.delete(path);
|
|
200
210
|
const affectedIds = new Set<string>();
|
|
201
211
|
for (const entry of entries) {
|
|
202
212
|
if (
|
|
@@ -211,7 +221,9 @@ async function collectSyncEvidence(git: GitRunner, root: string): Promise<SyncEv
|
|
|
211
221
|
const missingPaths = new Set<string>();
|
|
212
222
|
for (const entry of entries)
|
|
213
223
|
for (const path of entry.files) if (!(await isFile(join(root, path)))) missingPaths.add(path);
|
|
214
|
-
const siblingFiles = siblingEntries
|
|
224
|
+
const siblingFiles = siblingEntries
|
|
225
|
+
.flatMap((entry) => entry.files)
|
|
226
|
+
.filter((path) => !missingPaths.has(path) && isContextEligiblePath(path));
|
|
215
227
|
const eligibleFiles = new Set([...dirtyExisting, ...dependencies, ...siblingFiles]);
|
|
216
228
|
const structuralPreviews = new Map<string, string>();
|
|
217
229
|
for (const path of [...new Set(siblingFiles)].sort()) {
|
|
@@ -282,7 +294,21 @@ async function collectDirtyFiles(
|
|
|
282
294
|
index++;
|
|
283
295
|
}
|
|
284
296
|
}
|
|
285
|
-
const
|
|
297
|
+
const sensitive = parsed.filter(
|
|
298
|
+
(file) =>
|
|
299
|
+
file.kind !== "deleted" &&
|
|
300
|
+
(isSensitiveContextPath(file.path) || (file.oldPath !== undefined && isSensitiveContextPath(file.oldPath))),
|
|
301
|
+
);
|
|
302
|
+
if (sensitive.length > 0)
|
|
303
|
+
throw new Error(
|
|
304
|
+
`Sensitive files cannot be inspected by context sync:\n${sensitive.map((file) => `- ${file.path}`).join("\n")}`,
|
|
305
|
+
);
|
|
306
|
+
const sorted = parsed
|
|
307
|
+
.filter(
|
|
308
|
+
(file) =>
|
|
309
|
+
isContextEligiblePath(file.path) || (file.oldPath !== undefined && isContextEligiblePath(file.oldPath)),
|
|
310
|
+
)
|
|
311
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
286
312
|
const result: SyncDirtyFile[] = [];
|
|
287
313
|
for (let offset = 0; offset < sorted.length; offset += EVIDENCE_CONCURRENCY) {
|
|
288
314
|
result.push(
|
|
@@ -627,16 +653,16 @@ export async function applyContextSyncPlan(
|
|
|
627
653
|
for (const path of paths) originals.set(path, (await pathExists(path)) ? await readFile(path) : undefined);
|
|
628
654
|
const temporaryFiles = new Map<string, string>();
|
|
629
655
|
try {
|
|
630
|
-
for (const path of paths) {
|
|
631
|
-
const output = outputs.get(path);
|
|
632
|
-
if (output === undefined) continue;
|
|
633
|
-
await mkdir(dirname(path), { recursive: true });
|
|
634
|
-
const temporary = `${path}.${process.pid}.${Date.now()}.${temporaryFiles.size}.tmp`;
|
|
635
|
-
await writeFile(temporary, output, "utf8");
|
|
636
|
-
temporaryFiles.set(path, temporary);
|
|
637
|
-
}
|
|
638
656
|
await acquireMutationQueues(lockPaths, async () => {
|
|
639
657
|
await verifyFreshness?.();
|
|
658
|
+
for (const path of paths) {
|
|
659
|
+
const output = outputs.get(path);
|
|
660
|
+
if (output === undefined) continue;
|
|
661
|
+
await mkdir(dirname(path), { recursive: true });
|
|
662
|
+
const temporary = `${path}.${process.pid}.${Date.now()}.${temporaryFiles.size}.tmp`;
|
|
663
|
+
await writeFile(temporary, output, "utf8");
|
|
664
|
+
temporaryFiles.set(path, temporary);
|
|
665
|
+
}
|
|
640
666
|
const changed: string[] = [];
|
|
641
667
|
try {
|
|
642
668
|
for (const path of paths) {
|
|
@@ -41,14 +41,7 @@ export default function imageGenExtension(pi: ExtensionAPI): void {
|
|
|
41
41
|
name: "image_gen",
|
|
42
42
|
label: "Image Generation",
|
|
43
43
|
description:
|
|
44
|
-
"Generate a
|
|
45
|
-
promptSnippet: "Generate or edit raster images with Grok Imagine",
|
|
46
|
-
promptGuidelines: [
|
|
47
|
-
"Use image_gen when the user asks for a generated raster image or an AI edit of local raster images.",
|
|
48
|
-
"Omit referenced_image_paths when image_gen should create a new image.",
|
|
49
|
-
"Pass one to three local paths in referenced_image_paths when image_gen should edit or compose existing images.",
|
|
50
|
-
"Omit path for temporary external storage. Pass path only when the user wants the generated image saved in their repository or another explicit location.",
|
|
51
|
-
],
|
|
44
|
+
"Generate a requested raster image or AI-edit existing images with the xAI Grok subscription OAuth login. Omit referenced_image_paths to generate; pass one to three local paths to edit or compose. Omit path to use Tau's external image store; pass path only when the user explicitly requests a repository file or other destination. Returns the image for inspection.",
|
|
52
45
|
parameters: imageGenSchema,
|
|
53
46
|
async execute(_toolCallId, params: ImageGenParams, signal, onUpdate, ctx) {
|
|
54
47
|
signal?.throwIfAborted();
|
|
@@ -4,6 +4,7 @@ import { type ExecResult, type ExtensionAPI, keyText, type Theme } from "@earend
|
|
|
4
4
|
import { Box, Text } from "@earendil-works/pi-tui";
|
|
5
5
|
import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
|
|
6
6
|
import { resolveProjectRoot } from "../../shared/settings/paths.ts";
|
|
7
|
+
import { registerTauSystemPromptContribution } from "../../shared/system-prompt-contributions.ts";
|
|
7
8
|
import silentCommandRunnerSettings from "./settings.ts";
|
|
8
9
|
|
|
9
10
|
const MESSAGE_TYPE = "tau:silent-command-runner";
|
|
@@ -79,18 +80,17 @@ export default function silentCommandRunnerExtension(pi: ExtensionAPI): void {
|
|
|
79
80
|
let turnPaths = new Set<string>();
|
|
80
81
|
let run: Promise<void> | undefined;
|
|
81
82
|
let abortController: AbortController | undefined;
|
|
83
|
+
const unregisterPrompt = registerTauSystemPromptContribution({
|
|
84
|
+
id: "silent-command-runner.instructions",
|
|
85
|
+
order: 200,
|
|
86
|
+
render: () =>
|
|
87
|
+
settings.enabled && settings.commands.length > 0 ? formatSilentCheckPrompt(settings.commands) : undefined,
|
|
88
|
+
});
|
|
82
89
|
|
|
83
90
|
pi.registerMessageRenderer<FailureDetails>(MESSAGE_TYPE, (message, { expanded }, theme) =>
|
|
84
91
|
renderFailure(asFailureDetails(message.details), expanded, theme),
|
|
85
92
|
);
|
|
86
93
|
|
|
87
|
-
pi.on("before_agent_start", (event) => {
|
|
88
|
-
if (!settings.enabled || settings.commands.length === 0) return;
|
|
89
|
-
const prompt = formatSilentCheckPrompt(settings.commands);
|
|
90
|
-
event.systemPromptOptions.appendSystemPrompt = appendPrompt(event.systemPromptOptions.appendSystemPrompt, prompt);
|
|
91
|
-
return { systemPrompt: `${event.systemPrompt}\n\n${prompt}` };
|
|
92
|
-
});
|
|
93
|
-
|
|
94
94
|
pi.on("session_start", async (_event, ctx) => {
|
|
95
95
|
settings = normalizeSettings(await loadTauExtensionSettings(ctx, silentCommandRunnerSettings));
|
|
96
96
|
turnStart = Date.now();
|
|
@@ -122,6 +122,7 @@ export default function silentCommandRunnerExtension(pi: ExtensionAPI): void {
|
|
|
122
122
|
});
|
|
123
123
|
|
|
124
124
|
pi.on("session_shutdown", () => {
|
|
125
|
+
unregisterPrompt();
|
|
125
126
|
abortController?.abort();
|
|
126
127
|
abortController = undefined;
|
|
127
128
|
run = undefined;
|
|
@@ -206,10 +207,6 @@ function formatSilentCheckPrompt(commands: readonly CommandConfig[]): string {
|
|
|
206
207
|
].join("\n");
|
|
207
208
|
}
|
|
208
209
|
|
|
209
|
-
function appendPrompt(current: string | undefined, prompt: string): string {
|
|
210
|
-
return current ? `${current}\n\n${prompt}` : prompt;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
210
|
function formatSilentCheckCommand(command: CommandConfig): string {
|
|
214
211
|
return [
|
|
215
212
|
`- ${command.name}: ${command.command}`,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Soul replaces Pi's default assistant prompt with Rok: caveman voice plus old-code senior judgment.
|
|
4
4
|
|
|
5
|
-
It keeps the core prompt stable for cache reuse and
|
|
5
|
+
It keeps the core prompt stable for cache reuse. Soul supplies the current local date and the initial root directory snapshot as hidden session context; the working directory and project instructions remain in the system prompt.
|
|
6
6
|
|
|
7
7
|
Disable it in Tau settings:
|
|
8
8
|
|
package/extensions/soul/index.ts
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
|
|
3
|
-
import {
|
|
3
|
+
import { collectTauSystemPromptContributions } from "../../shared/system-prompt-contributions.ts";
|
|
4
|
+
import {
|
|
5
|
+
buildRokPrompt,
|
|
6
|
+
fingerprintRuntimeSnapshot,
|
|
7
|
+
formatLocalDateKey,
|
|
8
|
+
formatLocalDisplayDate,
|
|
9
|
+
formatRuntimeContextMessage,
|
|
10
|
+
freezeRuntimeContext,
|
|
11
|
+
type RuntimeContext,
|
|
12
|
+
} from "./prompt.ts";
|
|
4
13
|
import soulSettings from "./settings.ts";
|
|
5
14
|
|
|
15
|
+
const RUNTIME_CONTEXT_TYPE = "tau.runtime-context";
|
|
16
|
+
|
|
17
|
+
interface RuntimeContextMessageDetails {
|
|
18
|
+
version: 1;
|
|
19
|
+
dateKey: string;
|
|
20
|
+
snapshotHash: string;
|
|
21
|
+
includesSnapshot: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
6
24
|
export default function soulExtension(pi: ExtensionAPI): void {
|
|
7
25
|
let enabled = true;
|
|
8
26
|
let runtimeContext: RuntimeContext | undefined;
|
|
@@ -12,9 +30,72 @@ export default function soulExtension(pi: ExtensionAPI): void {
|
|
|
12
30
|
runtimeContext = freezeRuntimeContext(ctx.cwd);
|
|
13
31
|
});
|
|
14
32
|
|
|
15
|
-
pi.on("before_agent_start", (event, ctx) => {
|
|
16
|
-
if (!enabled) return undefined;
|
|
33
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
17
34
|
runtimeContext ??= freezeRuntimeContext(ctx.cwd);
|
|
18
|
-
|
|
35
|
+
// Pi reuses base prompt options, so Tau-owned additions compose here without accumulating mutations.
|
|
36
|
+
const contributions = await collectTauSystemPromptContributions(event, ctx);
|
|
37
|
+
const basePrompt = enabled ? buildRokPrompt(event.systemPromptOptions, runtimeContext) : event.systemPrompt;
|
|
38
|
+
const systemPrompt = [basePrompt, ...contributions].filter((block) => block.trim()).join("\n\n");
|
|
39
|
+
|
|
40
|
+
const now = new Date();
|
|
41
|
+
const dateKey = formatLocalDateKey(now);
|
|
42
|
+
const snapshotHash = fingerprintRuntimeSnapshot(runtimeContext);
|
|
43
|
+
let hasDate = false;
|
|
44
|
+
let hasSnapshot = false;
|
|
45
|
+
for (const entry of ctx.sessionManager.buildContextEntries()) {
|
|
46
|
+
const details = runtimeContextDetails(entry);
|
|
47
|
+
if (!details) continue;
|
|
48
|
+
if (details.dateKey === dateKey) hasDate = true;
|
|
49
|
+
if (details.includesSnapshot && details.snapshotHash === snapshotHash) hasSnapshot = true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const includeSnapshot = !hasSnapshot;
|
|
53
|
+
const message =
|
|
54
|
+
hasDate && hasSnapshot
|
|
55
|
+
? undefined
|
|
56
|
+
: {
|
|
57
|
+
customType: RUNTIME_CONTEXT_TYPE,
|
|
58
|
+
content: formatRuntimeContextMessage(
|
|
59
|
+
formatLocalDisplayDate(now),
|
|
60
|
+
includeSnapshot ? runtimeContext.rootSnapshot : undefined,
|
|
61
|
+
),
|
|
62
|
+
display: false,
|
|
63
|
+
details: {
|
|
64
|
+
version: 1,
|
|
65
|
+
dateKey,
|
|
66
|
+
snapshotHash,
|
|
67
|
+
includesSnapshot: includeSnapshot,
|
|
68
|
+
} satisfies RuntimeContextMessageDetails,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
if (!enabled && contributions.length === 0 && !message) return undefined;
|
|
72
|
+
return {
|
|
73
|
+
...(enabled || contributions.length > 0 ? { systemPrompt } : {}),
|
|
74
|
+
...(message ? { message } : {}),
|
|
75
|
+
};
|
|
19
76
|
});
|
|
20
77
|
}
|
|
78
|
+
|
|
79
|
+
function runtimeContextDetails(value: unknown): RuntimeContextMessageDetails | undefined {
|
|
80
|
+
if (!value || typeof value !== "object") return undefined;
|
|
81
|
+
const entry = value as Record<string, unknown>;
|
|
82
|
+
if (entry.type !== "custom_message" || entry.customType !== RUNTIME_CONTEXT_TYPE || entry.display !== false) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
if (!entry.details || typeof entry.details !== "object") return undefined;
|
|
86
|
+
const details = entry.details as Record<string, unknown>;
|
|
87
|
+
if (
|
|
88
|
+
details.version !== 1 ||
|
|
89
|
+
typeof details.dateKey !== "string" ||
|
|
90
|
+
typeof details.snapshotHash !== "string" ||
|
|
91
|
+
typeof details.includesSnapshot !== "boolean"
|
|
92
|
+
) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
version: 1,
|
|
97
|
+
dateKey: details.dateKey,
|
|
98
|
+
snapshotHash: details.snapshotHash,
|
|
99
|
+
includesSnapshot: details.includesSnapshot,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { type Dirent, existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
4
|
import { dirname, join, resolve } from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -19,7 +20,6 @@ interface SnapshotEntry {
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export interface RuntimeContext {
|
|
22
|
-
date: string;
|
|
23
23
|
cwd: string;
|
|
24
24
|
rootSnapshot: readonly string[];
|
|
25
25
|
}
|
|
@@ -73,21 +73,50 @@ export function buildRokPrompt(options: BuildSystemPromptOptions, runtimeContext
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
export function freezeRuntimeContext(cwd: string): RuntimeContext {
|
|
76
|
-
return {
|
|
76
|
+
return { cwd: cwd.replace(/\\/g, "/"), rootSnapshot: listRootSnapshot(cwd) };
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
function
|
|
79
|
+
export function formatLocalDateKey(date: Date): string {
|
|
80
80
|
const year = date.getFullYear();
|
|
81
81
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
82
82
|
const day = String(date.getDate()).padStart(2, "0");
|
|
83
83
|
return `${year}-${month}-${day}`;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
export function formatLocalDisplayDate(date: Date): string {
|
|
87
|
+
const months = [
|
|
88
|
+
"January",
|
|
89
|
+
"February",
|
|
90
|
+
"March",
|
|
91
|
+
"April",
|
|
92
|
+
"May",
|
|
93
|
+
"June",
|
|
94
|
+
"July",
|
|
95
|
+
"August",
|
|
96
|
+
"September",
|
|
97
|
+
"October",
|
|
98
|
+
"November",
|
|
99
|
+
"December",
|
|
100
|
+
];
|
|
101
|
+
return `${date.getDate()} ${months[date.getMonth()]} ${date.getFullYear()}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function fingerprintRuntimeSnapshot(context: RuntimeContext): string {
|
|
105
|
+
return createHash("sha256")
|
|
106
|
+
.update(JSON.stringify({ version: 1, cwd: context.cwd, paths: context.rootSnapshot }))
|
|
107
|
+
.digest("hex");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function formatRuntimeContextMessage(displayDate: string, rootSnapshot: readonly string[] | undefined): string {
|
|
111
|
+
const blocks = [`Current local date: ${displayDate}`];
|
|
112
|
+
if (rootSnapshot?.length) {
|
|
113
|
+
blocks.push(`Root directory snapshot (depth 2):\n${rootSnapshot.map((path) => `- ${path}`).join("\n")}`);
|
|
114
|
+
}
|
|
115
|
+
return blocks.join("\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
86
118
|
function formatRuntimeContext(context: RuntimeContext): string {
|
|
87
|
-
|
|
88
|
-
? `\nRoot directory snapshot (depth 2):\n${context.rootSnapshot.map((path) => `- ${path}`).join("\n")}`
|
|
89
|
-
: "";
|
|
90
|
-
return `Current date: ${context.date}\nCurrent working directory: ${context.cwd}${rootSnapshot}`;
|
|
119
|
+
return `Current working directory: ${context.cwd}`;
|
|
91
120
|
}
|
|
92
121
|
|
|
93
122
|
function listRootSnapshot(cwd: string): string[] {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { createToolRowStateStore } from "../../shared/tool-row-state.js";
|
|
4
|
+
import { registerTauSystemPromptContribution } from "../../shared/system-prompt-contributions.ts";
|
|
4
5
|
import { discoverAgents, type AgentDiscovery } from "./agents.ts";
|
|
5
6
|
import { renderSubagentCall, renderSubagentResult } from "./render.ts";
|
|
6
7
|
import { extensionPathsForTools, FifoGate, runSubagent, type SubagentDetails } from "./run.ts";
|
|
@@ -33,6 +34,19 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
33
34
|
}
|
|
34
35
|
for (const path of fingerprints.keys()) if (!current.has(path)) fingerprints.delete(path);
|
|
35
36
|
};
|
|
37
|
+
const unregisterPrompt = registerTauSystemPromptContribution({
|
|
38
|
+
id: "subagent.catalog",
|
|
39
|
+
order: 300,
|
|
40
|
+
render: async (_event, ctx) => {
|
|
41
|
+
if (!pi.getActiveTools().includes("subagent")) return undefined;
|
|
42
|
+
const discovery = await discoverAgents(ctx.cwd, ctx.isProjectTrusted());
|
|
43
|
+
warn(discovery, ctx);
|
|
44
|
+
const lines = [...discovery.agents.values()]
|
|
45
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
46
|
+
.map((agent) => `- ${agent.name}: ${agent.description}`);
|
|
47
|
+
return `## Subagents\nUse \`subagent\` when an available agent matches a focused part of the task.\n\nAvailable agents for this turn:\n${lines.join("\n")}\n\nDelegate one focused task per call. Children do not inherit parent messages. Include exact absolute reference paths when a child must inspect a repository outside the current working directory.`;
|
|
48
|
+
},
|
|
49
|
+
});
|
|
36
50
|
pi.registerTool(
|
|
37
51
|
defineTool<typeof params, SubagentDetails>({
|
|
38
52
|
name: "subagent",
|
|
@@ -184,21 +198,6 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
184
198
|
},
|
|
185
199
|
}),
|
|
186
200
|
);
|
|
187
|
-
pi.on("before_agent_start", async (event, ctx) => {
|
|
188
|
-
if (!pi.getActiveTools().includes("subagent")) return;
|
|
189
|
-
const discovery = await discoverAgents(ctx.cwd, ctx.isProjectTrusted());
|
|
190
|
-
warn(discovery, ctx);
|
|
191
|
-
const lines = [...discovery.agents.values()]
|
|
192
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
193
|
-
.map((agent) => `- ${agent.name}: ${agent.description}`);
|
|
194
|
-
const prompt = `## Subagents\nUse \`subagent\` when an available agent matches a focused part of the task.\n\nAvailable agents for this turn:\n${lines.join("\n")}\n\nDelegate one focused task per call. Children do not inherit parent messages. Include exact absolute reference paths when a child must inspect a repository outside the current working directory.`;
|
|
195
|
-
event.systemPromptOptions.appendSystemPrompt = event.systemPromptOptions.appendSystemPrompt
|
|
196
|
-
? `${event.systemPromptOptions.appendSystemPrompt}\n\n${prompt}`
|
|
197
|
-
: prompt;
|
|
198
|
-
return {
|
|
199
|
-
systemPrompt: `${event.systemPrompt}\n\n${prompt}`,
|
|
200
|
-
};
|
|
201
|
-
});
|
|
202
201
|
pi.on("tool_result", (event) => {
|
|
203
202
|
if (event.toolName !== "subagent") return;
|
|
204
203
|
const details = event.details as SubagentDetails | undefined;
|
|
@@ -209,6 +208,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
209
208
|
runtimeWarnings.clear();
|
|
210
209
|
});
|
|
211
210
|
pi.on("session_shutdown", () => {
|
|
211
|
+
unregisterPrompt();
|
|
212
212
|
for (const controller of controllers) controller.abort();
|
|
213
213
|
controllers.clear();
|
|
214
214
|
});
|
|
@@ -98,13 +98,17 @@ Adds `/tau-help` to show this guide as rendered Markdown in the chat.
|
|
|
98
98
|
|
|
99
99
|
Adds `/tau`, `/tau init [--global|--project]`, and `/tau doctor` for Tau setup and diagnostics.
|
|
100
100
|
|
|
101
|
+
## tool-loader
|
|
102
|
+
|
|
103
|
+
Progressively exposes specialist tools through `load_tools`. Tau normally loads the fixed `web`, `image`, and `appshot` groups itself when needed; supported providers can preserve more prompt-cache reuse.
|
|
104
|
+
|
|
101
105
|
## turn-budget
|
|
102
106
|
|
|
103
107
|
Tracks and limits agent turns to keep work bounded.
|
|
104
108
|
|
|
105
109
|
## web
|
|
106
110
|
|
|
107
|
-
Gives the agent compact `
|
|
111
|
+
Gives the agent compact `websearch`, `webfetch`, and `codesearch` tools for web and implementation research.
|
|
108
112
|
|
|
109
113
|
## Prompts
|
|
110
114
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Tool Loader
|
|
2
|
+
|
|
3
|
+
Tau progressively exposes specialist tools. Most coding turns do not need web, image, or macOS application schemas, so Pi can load those tools later without discarding supported provider cache prefixes.
|
|
4
|
+
|
|
5
|
+
The agent normally calls `load_tools` itself. Users can also ask Tau to load one of these groups:
|
|
6
|
+
|
|
7
|
+
- `web` for public web and implementation research
|
|
8
|
+
- `image` for raster image generation and editing
|
|
9
|
+
- `appshot` for macOS window discovery, capture, and activation
|
|
10
|
+
|
|
11
|
+
Supported models optimize prompt caching when a group loads. Other models keep the same functional behavior.
|
|
12
|
+
|
|
13
|
+
After changing this extension during development, run `/reload` before testing.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { type Static, Type } from "typebox";
|
|
4
|
+
|
|
5
|
+
const CAPABILITIES = ["web", "image", "appshot"] as const;
|
|
6
|
+
type Capability = (typeof CAPABILITIES)[number];
|
|
7
|
+
|
|
8
|
+
const CAPABILITY_TOOLS: Record<Capability, readonly string[]> = {
|
|
9
|
+
web: ["webfetch", "websearch", "codesearch"],
|
|
10
|
+
image: ["image_gen"],
|
|
11
|
+
appshot: ["list_windows", "screenshot_window", "activate_app"],
|
|
12
|
+
};
|
|
13
|
+
const SPECIALIST_TOOLS = CAPABILITIES.flatMap((capability) => CAPABILITY_TOOLS[capability]);
|
|
14
|
+
|
|
15
|
+
const loadToolsSchema = Type.Object(
|
|
16
|
+
{
|
|
17
|
+
capability: StringEnum(CAPABILITIES, {
|
|
18
|
+
description: "Specialist group to load: web, image, or appshot",
|
|
19
|
+
}),
|
|
20
|
+
},
|
|
21
|
+
{ additionalProperties: false },
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
type LoadToolsParams = Static<typeof loadToolsSchema>;
|
|
25
|
+
|
|
26
|
+
interface LoadToolsDetails {
|
|
27
|
+
version: 1;
|
|
28
|
+
capability: Capability;
|
|
29
|
+
requestedToolNames: string[];
|
|
30
|
+
addedToolNames: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default function toolLoaderExtension(pi: ExtensionAPI): void {
|
|
34
|
+
let managed = false;
|
|
35
|
+
let allowedSpecialistNames = new Set<string>();
|
|
36
|
+
|
|
37
|
+
pi.registerTool(
|
|
38
|
+
defineTool<typeof loadToolsSchema, LoadToolsDetails>({
|
|
39
|
+
name: "load_tools",
|
|
40
|
+
label: "Load Tools",
|
|
41
|
+
description:
|
|
42
|
+
"Load one Tau specialist tool group for the current session. Groups: web for public web and implementation research; image for raster generation and editing; appshot for macOS window discovery, capture, and activation.",
|
|
43
|
+
promptSnippet: "Load a specialist Tau tool group for web research, image generation, or macOS app inspection",
|
|
44
|
+
promptGuidelines: [
|
|
45
|
+
"Use load_tools before attempting a specialist capability whose tools are not currently available.",
|
|
46
|
+
],
|
|
47
|
+
parameters: loadToolsSchema,
|
|
48
|
+
async execute(_toolCallId, params: LoadToolsParams) {
|
|
49
|
+
const before = pi.getActiveTools();
|
|
50
|
+
const requested = [...CAPABILITY_TOOLS[params.capability]];
|
|
51
|
+
const registered = new Set(pi.getAllTools().map((tool) => tool.name));
|
|
52
|
+
const loadable = requested.filter((name) => registered.has(name) && allowedSpecialistNames.has(name));
|
|
53
|
+
if (loadable.length === 0) {
|
|
54
|
+
throw new Error(`No ${params.capability} tools are available in this session's tool configuration.`);
|
|
55
|
+
}
|
|
56
|
+
const beforeSet = new Set(before);
|
|
57
|
+
const next = [...before, ...loadable.filter((name) => !beforeSet.has(name))];
|
|
58
|
+
pi.setActiveTools(next);
|
|
59
|
+
const after = pi.getActiveTools();
|
|
60
|
+
const addedToolNames = after.filter((name) => !beforeSet.has(name));
|
|
61
|
+
const available = requested.filter((name) => after.includes(name));
|
|
62
|
+
const unavailable = requested.filter((name) => !after.includes(name));
|
|
63
|
+
const label = `${params.capability[0]?.toUpperCase()}${params.capability.slice(1)}`;
|
|
64
|
+
const text =
|
|
65
|
+
addedToolNames.length > 0
|
|
66
|
+
? `Loaded ${params.capability} tools: ${addedToolNames.join(", ")}.`
|
|
67
|
+
: `${label} tools are already loaded: ${available.join(", ")}.`;
|
|
68
|
+
return {
|
|
69
|
+
content: [
|
|
70
|
+
{
|
|
71
|
+
type: "text",
|
|
72
|
+
text: unavailable.length ? `${text} Unavailable: ${unavailable.join(", ")}.` : text,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
details: {
|
|
76
|
+
version: 1,
|
|
77
|
+
capability: params.capability,
|
|
78
|
+
requestedToolNames: requested,
|
|
79
|
+
addedToolNames,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
pi.on("session_start", (_event, ctx) => {
|
|
87
|
+
const initial = pi.getActiveTools();
|
|
88
|
+
const initialSet = new Set(initial);
|
|
89
|
+
allowedSpecialistNames = new Set(SPECIALIST_TOOLS.filter((name) => initialSet.has(name)));
|
|
90
|
+
managed = initialSet.has("load_tools") && SPECIALIST_TOOLS.every((name) => initialSet.has(name));
|
|
91
|
+
if (managed) restoreActiveTools(pi, initial, loadedCapabilities(ctx.sessionManager.getBranch()));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
95
|
+
if (managed) restoreActiveTools(pi, pi.getActiveTools(), loadedCapabilities(ctx.sessionManager.getBranch()));
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function restoreActiveTools(pi: ExtensionAPI, current: readonly string[], loaded: ReadonlySet<Capability>): void {
|
|
100
|
+
const specialist = new Set(SPECIALIST_TOOLS);
|
|
101
|
+
const next = current.filter((name) => !specialist.has(name));
|
|
102
|
+
for (const capability of CAPABILITIES) {
|
|
103
|
+
if (loaded.has(capability)) next.push(...CAPABILITY_TOOLS[capability]);
|
|
104
|
+
}
|
|
105
|
+
pi.setActiveTools([...new Set(next)]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function loadedCapabilities(entries: readonly unknown[]): Set<Capability> {
|
|
109
|
+
const loaded = new Set<Capability>();
|
|
110
|
+
for (const value of entries) {
|
|
111
|
+
if (!value || typeof value !== "object") continue;
|
|
112
|
+
const entry = value as Record<string, unknown>;
|
|
113
|
+
if (entry.type !== "message" || !entry.message || typeof entry.message !== "object") continue;
|
|
114
|
+
const message = entry.message as Record<string, unknown>;
|
|
115
|
+
if (message.role !== "toolResult" || message.toolName !== "load_tools" || message.isError === true) continue;
|
|
116
|
+
if (!isLoadToolsDetails(message.details)) continue;
|
|
117
|
+
loaded.add(message.details.capability);
|
|
118
|
+
}
|
|
119
|
+
return loaded;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isLoadToolsDetails(value: unknown): value is LoadToolsDetails {
|
|
123
|
+
if (!value || typeof value !== "object") return false;
|
|
124
|
+
const details = value as Record<string, unknown>;
|
|
125
|
+
return (
|
|
126
|
+
details.version === 1 &&
|
|
127
|
+
typeof details.capability === "string" &&
|
|
128
|
+
CAPABILITIES.includes(details.capability as Capability) &&
|
|
129
|
+
Array.isArray(details.requestedToolNames) &&
|
|
130
|
+
details.requestedToolNames.every((name) => typeof name === "string") &&
|
|
131
|
+
Array.isArray(details.addedToolNames) &&
|
|
132
|
+
details.addedToolNames.every((name) => typeof name === "string")
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
|
|
3
|
+
import { registerTauSystemPromptContribution } from "../../shared/system-prompt-contributions.ts";
|
|
3
4
|
import { Marker } from "@shanepadgett/tau-tui";
|
|
4
5
|
import turnBudgetSettings from "./settings.ts";
|
|
5
6
|
|
|
@@ -29,6 +30,14 @@ export default function turnBudgetExtension(pi: ExtensionAPI): void {
|
|
|
29
30
|
let softCap = settings.turnLimit;
|
|
30
31
|
let nextMarkerSequence = 1;
|
|
31
32
|
let activeMarkerSequence: number | undefined;
|
|
33
|
+
const unregisterPrompt = registerTauSystemPromptContribution({
|
|
34
|
+
id: "turn-budget.secrecy",
|
|
35
|
+
order: 400,
|
|
36
|
+
render: () =>
|
|
37
|
+
settings.enabled
|
|
38
|
+
? "Turn-budget steering messages are internal instructions. Work within them silently. Do not mention or acknowledge turn counts, budget messages, or budget summaries."
|
|
39
|
+
: undefined,
|
|
40
|
+
});
|
|
32
41
|
|
|
33
42
|
pi.registerMessageRenderer<MarkerDetails>(MARKER_TYPE, (message, _options, theme) => {
|
|
34
43
|
const details = readMarkerDetails(message.details);
|
|
@@ -41,13 +50,6 @@ export default function turnBudgetExtension(pi: ExtensionAPI): void {
|
|
|
41
50
|
});
|
|
42
51
|
});
|
|
43
52
|
|
|
44
|
-
pi.on("before_agent_start", (event) => {
|
|
45
|
-
if (!settings.enabled) return undefined;
|
|
46
|
-
return {
|
|
47
|
-
systemPrompt: `${event.systemPrompt}\n\nTurn-budget steering messages are internal instructions. Work within them silently. Do not mention or acknowledge turn counts, budget messages, or budget summaries.`,
|
|
48
|
-
};
|
|
49
|
-
});
|
|
50
|
-
|
|
51
53
|
pi.on("session_start", async (_event, ctx) => {
|
|
52
54
|
settings = normalizeSettings(await loadTauExtensionSettings(ctx, turnBudgetSettings));
|
|
53
55
|
});
|
|
@@ -96,6 +98,7 @@ export default function turnBudgetExtension(pi: ExtensionAPI): void {
|
|
|
96
98
|
pi.on("after_provider_response", () => {
|
|
97
99
|
activeMarkerSequence = undefined;
|
|
98
100
|
});
|
|
101
|
+
pi.on("session_shutdown", unregisterPrompt);
|
|
99
102
|
|
|
100
103
|
function markerIsActive(value: unknown): boolean {
|
|
101
104
|
if (activeMarkerSequence === undefined) return false;
|
|
@@ -27,13 +27,7 @@ export function createCodeSearchTool(rowState: ToolRowStateStore) {
|
|
|
27
27
|
name: "codesearch",
|
|
28
28
|
label: "Code Search",
|
|
29
29
|
description:
|
|
30
|
-
"Search Exa for
|
|
31
|
-
promptSnippet: "Search code and documentation context for implementation details",
|
|
32
|
-
promptGuidelines: [
|
|
33
|
-
"Use codesearch for API usage, code examples, and implementation-oriented documentation.",
|
|
34
|
-
"Use websearch for broad discovery and webfetch for a known URL.",
|
|
35
|
-
"Use a separate research workflow instead of codesearch when several searches, fetches, and synthesis are needed.",
|
|
36
|
-
],
|
|
30
|
+
"Search Exa for API usage, code examples, and implementation-oriented documentation context. Use websearch for broad discovery and webfetch for a known URL. Use a separate research workflow when several searches, fetches, and synthesis are needed. Output is truncated to 2,000 lines or 50 KB.",
|
|
37
31
|
parameters: codeSearchParams,
|
|
38
32
|
async execute(_toolCallId, params, signal, onUpdate) {
|
|
39
33
|
const timeout = normalizeTimeout(params.timeout, 25);
|
|
@@ -66,13 +66,7 @@ export function createWebFetchTool(rowState: ToolRowStateStore) {
|
|
|
66
66
|
name: "webfetch",
|
|
67
67
|
label: "Web Fetch",
|
|
68
68
|
description:
|
|
69
|
-
"Fetch a known HTTP(S) URL as Markdown, text, or HTML. Supports inline images, limits response bodies to 5 MB, and truncates text to 2,000 lines or 50 KB.",
|
|
70
|
-
promptSnippet: "Fetch a specific URL and extract readable content",
|
|
71
|
-
promptGuidelines: [
|
|
72
|
-
"Use webfetch when you already have a URL and need its content.",
|
|
73
|
-
"Use websearch for broad discovery and codesearch for implementation-oriented lookups.",
|
|
74
|
-
"Use a separate research workflow instead of webfetch when several searches, fetches, and synthesis are needed.",
|
|
75
|
-
],
|
|
69
|
+
"Fetch a known HTTP(S) URL as Markdown, text, or HTML. Use webfetch when you already have a URL; use websearch for broad discovery and codesearch for implementation-oriented lookups. Use a separate research workflow when several searches, fetches, and synthesis are needed. Supports inline images, limits response bodies to 5 MB, and truncates text to 2,000 lines or 50 KB.",
|
|
76
70
|
parameters: webFetchParams,
|
|
77
71
|
async execute(_toolCallId, params, signal, onUpdate) {
|
|
78
72
|
let url: URL;
|
|
@@ -36,13 +36,7 @@ export function createWebSearchTool(rowState: ToolRowStateStore) {
|
|
|
36
36
|
name: "websearch",
|
|
37
37
|
label: "Web Search",
|
|
38
38
|
description:
|
|
39
|
-
"Search the public web through Exa for current information and relevant pages. Output is truncated to 2,000 lines or 50 KB.",
|
|
40
|
-
promptSnippet: "Search the public web for current or external information",
|
|
41
|
-
promptGuidelines: [
|
|
42
|
-
"Use websearch for broad discovery, then webfetch when you have a specific URL.",
|
|
43
|
-
"Use codesearch for implementation-oriented code and documentation context.",
|
|
44
|
-
"Use a separate research workflow instead of websearch when several searches, fetches, and synthesis are needed.",
|
|
45
|
-
],
|
|
39
|
+
"Search the public web through Exa for current information and relevant pages. Use websearch for broad discovery, then webfetch for a known URL; use codesearch for implementation-oriented code and documentation context. Use a separate research workflow when several searches, fetches, and synthesis are needed. Output is truncated to 2,000 lines or 50 KB.",
|
|
46
40
|
parameters: webSearchParams,
|
|
47
41
|
async execute(_toolCallId, params, signal, onUpdate) {
|
|
48
42
|
const timeout = normalizeTimeout(params.timeout, 25);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shanepadgett/tau-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Tau is a custom agentic harness built with pi extensions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"README.md"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@shanepadgett/tau-tui": "0.
|
|
31
|
+
"@shanepadgett/tau-tui": "0.8.0",
|
|
32
32
|
"@toon-format/toon": "2.3.0",
|
|
33
33
|
"smol-toml": "1.7.0"
|
|
34
34
|
},
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import type { Api, AssistantMessage, Message, Model, ThinkingLevel, Tool } from "@earendil-works/pi-ai";
|
|
2
3
|
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
3
4
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -13,7 +14,6 @@ const SEVEN_DAYS_MS = 604_800_000;
|
|
|
13
14
|
interface GenerationContext {
|
|
14
15
|
ui: ExtensionContext["ui"];
|
|
15
16
|
signal: AbortSignal | undefined;
|
|
16
|
-
sessionManager?: { getSessionId(): string };
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
interface ModelFallbackOptions {
|
|
@@ -131,9 +131,10 @@ async function requestValidated<T>(
|
|
|
131
131
|
): Promise<T> {
|
|
132
132
|
const userMessage: Message = { role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() };
|
|
133
133
|
const messages: Message[] = [userMessage];
|
|
134
|
+
const sessionId = randomUUID();
|
|
134
135
|
|
|
135
136
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
136
|
-
const response = await completeCandidate(ctx, candidate, messages);
|
|
137
|
+
const response = await completeCandidate(ctx, candidate, messages, sessionId);
|
|
137
138
|
const text = responseText(response);
|
|
138
139
|
if (response.stopReason === "error") {
|
|
139
140
|
const error = new Error(response.errorMessage || "model returned an error");
|
|
@@ -169,9 +170,10 @@ async function requestToolValidated<T>(
|
|
|
169
170
|
maxAttempts = MAX_TOOL_ATTEMPTS,
|
|
170
171
|
): Promise<T> {
|
|
171
172
|
const messages: Message[] = [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }];
|
|
173
|
+
const sessionId = randomUUID();
|
|
172
174
|
|
|
173
175
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
174
|
-
const response = await completeCandidate(ctx, candidate, messages, [tool]);
|
|
176
|
+
const response = await completeCandidate(ctx, candidate, messages, sessionId, [tool]);
|
|
175
177
|
const text = responseText(response);
|
|
176
178
|
const toolCalls = response.content.flatMap((part) => (part.type === "toolCall" ? [part] : []));
|
|
177
179
|
const output = text || formatToolCalls(toolCalls);
|
|
@@ -206,6 +208,7 @@ function completeCandidate(
|
|
|
206
208
|
ctx: GenerationContext,
|
|
207
209
|
candidate: ModelCandidate,
|
|
208
210
|
messages: readonly Message[],
|
|
211
|
+
sessionId: string,
|
|
209
212
|
tools?: Tool[],
|
|
210
213
|
): Promise<AssistantMessage> {
|
|
211
214
|
return completeSimple(candidate.model, tools ? { messages: [...messages], tools } : { messages: [...messages] }, {
|
|
@@ -213,7 +216,7 @@ function completeCandidate(
|
|
|
213
216
|
headers: candidate.headers,
|
|
214
217
|
signal: ctx.signal,
|
|
215
218
|
reasoning: candidate.reasoning,
|
|
216
|
-
sessionId
|
|
219
|
+
sessionId,
|
|
217
220
|
});
|
|
218
221
|
}
|
|
219
222
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { BeforeAgentStartEvent, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
interface TauSystemPromptContribution {
|
|
4
|
+
id: string;
|
|
5
|
+
order: number;
|
|
6
|
+
render: (event: BeforeAgentStartEvent, ctx: ExtensionContext) => string | undefined | Promise<string | undefined>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface Registration extends TauSystemPromptContribution {
|
|
10
|
+
token: symbol;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const registrations = new Map<string, Registration>();
|
|
14
|
+
|
|
15
|
+
export function registerTauSystemPromptContribution(contribution: TauSystemPromptContribution): () => void {
|
|
16
|
+
const token = Symbol(contribution.id);
|
|
17
|
+
registrations.set(contribution.id, { ...contribution, token });
|
|
18
|
+
return () => {
|
|
19
|
+
if (registrations.get(contribution.id)?.token === token) registrations.delete(contribution.id);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function collectTauSystemPromptContributions(
|
|
24
|
+
event: BeforeAgentStartEvent,
|
|
25
|
+
ctx: ExtensionContext,
|
|
26
|
+
): Promise<string[]> {
|
|
27
|
+
const ordered = [...registrations.values()].sort(
|
|
28
|
+
(left, right) => left.order - right.order || left.id.localeCompare(right.id),
|
|
29
|
+
);
|
|
30
|
+
const rendered = await Promise.all(ordered.map((contribution) => contribution.render(event, ctx)));
|
|
31
|
+
const seen = new Set<string>();
|
|
32
|
+
const blocks: string[] = [];
|
|
33
|
+
for (const value of rendered) {
|
|
34
|
+
const block = value?.trim();
|
|
35
|
+
if (!block || seen.has(block)) continue;
|
|
36
|
+
seen.add(block);
|
|
37
|
+
blocks.push(block);
|
|
38
|
+
}
|
|
39
|
+
return blocks;
|
|
40
|
+
}
|