@shanepadgett/tau-agent 0.7.2 → 0.9.1

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.
@@ -28,7 +28,7 @@ Adds `/commit` for semantic commit grouping, review, and committing selected rep
28
28
 
29
29
  ## context
30
30
 
31
- Adds `/context` to select reusable repository work scopes from `.pi/contexts`, and `/context-sync` to reconcile affected scopes from current Git changes. Folder names are tabs, TOML files are concepts, and TOML sections are selectable entries.
31
+ Adds `/context` to select reusable repository work scopes from `.pi/contexts`, and `/context-sync` to reconcile affected scopes from current Git changes. Tau automatically validates changed-file membership and stale references after agent turns. Folder names are tabs, TOML files are concepts, and TOML sections are selectable entries.
32
32
 
33
33
  ## explore
34
34
 
@@ -74,13 +74,17 @@ Adds `/reference` to manage separate repositories kept outside the current proje
74
74
 
75
75
  Shows a compact display-only marker after each run with wall time and model cost. It does not enter agent context.
76
76
 
77
+ ## runtime-context
78
+
79
+ Supplies the agent with the current local date and an initial root directory snapshot as hidden session context.
80
+
77
81
  ## silent-command-runner
78
82
 
79
83
  Runs configured commands while keeping their output out of agent context when that is useful.
80
84
 
81
85
  ## soul
82
86
 
83
- Manages the session’s soul prompt and its persistent working guidance.
87
+ Adds Rok’s persona and operating rules to Pi’s native assistant prompt.
84
88
 
85
89
  ## stash
86
90
 
@@ -98,13 +102,17 @@ Adds `/tau-help` to show this guide as rendered Markdown in the chat.
98
102
 
99
103
  Adds `/tau`, `/tau init [--global|--project]`, and `/tau doctor` for Tau setup and diagnostics.
100
104
 
105
+ ## tool-loader
106
+
107
+ 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.
108
+
101
109
  ## turn-budget
102
110
 
103
111
  Tracks and limits agent turns to keep work bounded.
104
112
 
105
113
  ## web
106
114
 
107
- Gives the agent compact `web_search`, `web_fetch`, and `code_search` tools for web and implementation research.
115
+ Gives the agent compact `websearch`, `webfetch`, and `codesearch` tools for web and implementation research.
108
116
 
109
117
  ## Prompts
110
118
 
@@ -4,6 +4,14 @@ import { fileURLToPath } from "node:url";
4
4
  import { dirname, join } from "node:path";
5
5
  import { Markdown, type Component, visibleWidth } from "@earendil-works/pi-tui";
6
6
 
7
+ const TAU_DOCS_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "docs");
8
+ const TAU_DOCS_GUIDANCE = `Tau Agent documentation (read only when the user asks about Tau Agent, Rok, Tau extensions, Tau event APIs, harness behavior, or extending Tau Agent):
9
+ - Tau Agent docs: ${TAU_DOCS_PATH}
10
+ - When asked about: public events / external integration (docs/extending-tau-agent.md), custom subagents (docs/subagents.md), Tau TUI components (docs/tui.md)
11
+ - Resolve Tau docs/... under Tau Agent docs, not the current working directory
12
+ - When working on Tau topics, read the docs and follow .md cross-references before implementing
13
+ - Do not read Tau Agent docs for normal coding tasks`;
14
+
7
15
  const TAU_SYMBOL = [
8
16
  " %%%%%%%%#######*******+++++",
9
17
  " @@%%%%%%%########*******+++++++",
@@ -46,6 +54,10 @@ class TauHelpMessage implements Component {
46
54
  }
47
55
 
48
56
  export default function tauHelpExtension(pi: ExtensionAPI): void {
57
+ pi.on("before_agent_start", (event) => ({
58
+ systemPrompt: `${event.systemPrompt}\n\n${TAU_DOCS_GUIDANCE}`,
59
+ }));
60
+
49
61
  pi.registerMessageRenderer("tau-help", (message, _options, _theme) => {
50
62
  if (typeof message.content !== "string") return undefined;
51
63
  return new TauHelpMessage(message.content, getMarkdownTheme());
@@ -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
+ }
@@ -41,13 +41,6 @@ export default function turnBudgetExtension(pi: ExtensionAPI): void {
41
41
  });
42
42
  });
43
43
 
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
44
  pi.on("session_start", async (_event, ctx) => {
52
45
  settings = normalizeSettings(await loadTauExtensionSettings(ctx, turnBudgetSettings));
53
46
  });
@@ -96,7 +89,6 @@ export default function turnBudgetExtension(pi: ExtensionAPI): void {
96
89
  pi.on("after_provider_response", () => {
97
90
  activeMarkerSequence = undefined;
98
91
  });
99
-
100
92
  function markerIsActive(value: unknown): boolean {
101
93
  if (activeMarkerSequence === undefined) return false;
102
94
  const details = readMarkerDetails(value);
@@ -114,10 +106,12 @@ function sendTurnBudgetMessage(pi: ExtensionAPI, hint: Hint, sequence: number):
114
106
  }
115
107
 
116
108
  function formatSteeringMessage(hint: Hint): string {
109
+ const instruction =
110
+ "Internal steering instruction. Work within it silently. Do not mention or acknowledge turn counts, budget messages, or budget summaries.";
117
111
  if (hint.kind === "extended") {
118
- return `Turn budget: ${hint.used}/${hint.previousCap} turns used for this user prompt. Soft cap extended to ${hint.newCap}. Batch tools when more tool work remains.`;
112
+ return `${instruction} Turn budget: ${hint.used}/${hint.previousCap} turns used for this user prompt. Soft cap extended to ${hint.newCap}. Batch tools when more tool work remains.`;
119
113
  }
120
- return `Turn budget: ${hint.used}/${hint.cap} turns used for this user prompt. Batch tools when more tool work remains.`;
114
+ return `${instruction} Turn budget: ${hint.used}/${hint.cap} turns used for this user prompt. Batch tools when more tool work remains.`;
121
115
  }
122
116
 
123
117
  function normalizeSettings(value: typeof turnBudgetSettings.defaults): Settings {
@@ -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 implementation-oriented code and documentation context. Output is truncated to 2,000 lines or 50 KB.",
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.7.2",
3
+ "version": "0.9.1",
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.7.2",
31
+ "@shanepadgett/tau-tui": "0.9.1",
32
32
  "@toon-format/toon": "2.3.0",
33
33
  "smol-toml": "1.7.0"
34
34
  },
@@ -12,6 +12,31 @@
12
12
  "extensions": {
13
13
  "type": "object",
14
14
  "properties": {
15
+ "context": {
16
+ "type": "object",
17
+ "properties": {
18
+ "validation": {
19
+ "type": "object",
20
+ "properties": {
21
+ "enabled": {
22
+ "type": "boolean",
23
+ "default": true,
24
+ "description": "Validate context membership after agent turns."
25
+ },
26
+ "ignoreGlobs": {
27
+ "type": "array",
28
+ "items": {
29
+ "type": "string"
30
+ },
31
+ "default": [],
32
+ "description": "Project-relative files excluded from context membership validation and sync."
33
+ }
34
+ },
35
+ "additionalProperties": false
36
+ }
37
+ },
38
+ "additionalProperties": false
39
+ },
15
40
  "footer": {
16
41
  "type": "object",
17
42
  "properties": {
package/shared/glob.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { sep } from "node:path";
2
+
3
+ export function matchGlob(pattern: string, path: string): boolean {
4
+ return matchSegments(normalizeGlob(pattern).split("/"), normalizeGlob(path).split("/"));
5
+ }
6
+
7
+ function matchSegments(pattern: readonly string[], path: readonly string[]): boolean {
8
+ const [head, ...tail] = pattern;
9
+ if (head === undefined) return path.length === 0;
10
+ if (head === "**") return matchSegments(tail, path) || (path.length > 0 && matchSegments(pattern, path.slice(1)));
11
+ const [pathHead, ...pathTail] = path;
12
+ return pathHead !== undefined && matchSegment(head, pathHead) && matchSegments(tail, pathTail);
13
+ }
14
+
15
+ function matchSegment(pattern: string, value: string): boolean {
16
+ const source = [...pattern]
17
+ .map((char) => {
18
+ if (char === "*") return "[^/]*";
19
+ if (char === "?") return "[^/]";
20
+ return char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
21
+ })
22
+ .join("");
23
+ return new RegExp(`^${source}$`).test(value);
24
+ }
25
+
26
+ function normalizeGlob(value: string): string {
27
+ return posixPath(value.trim())
28
+ .replace(/^\.\//, "")
29
+ .replace(/^\/+|\/+$/g, "");
30
+ }
31
+
32
+ export function posixPath(value: string): string {
33
+ return sep === "/" ? value : value.split(sep).join("/");
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: ctx.sessionManager?.getSessionId(),
219
+ sessionId,
217
220
  });
218
221
  }
219
222