agent-sh 0.8.0 → 0.9.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 +25 -34
- package/dist/agent/agent-loop.d.ts +29 -6
- package/dist/agent/agent-loop.js +177 -59
- package/dist/agent/conversation-state.d.ts +3 -1
- package/dist/agent/conversation-state.js +6 -2
- package/dist/agent/nuclear-form.js +5 -4
- package/dist/agent/system-prompt.d.ts +4 -5
- package/dist/agent/system-prompt.js +12 -28
- package/dist/{token-budget.js → agent/token-budget.js} +1 -1
- package/dist/agent/tool-protocol.d.ts +83 -0
- package/dist/agent/tool-protocol.js +386 -0
- package/dist/agent/types.d.ts +21 -1
- package/dist/core.d.ts +7 -7
- package/dist/core.js +76 -194
- package/dist/event-bus.d.ts +26 -0
- package/dist/event-bus.js +20 -1
- package/dist/extension-loader.d.ts +5 -0
- package/dist/extension-loader.js +104 -17
- package/dist/extensions/agent-backend.d.ts +13 -0
- package/dist/extensions/agent-backend.js +167 -0
- package/dist/extensions/command-suggest.d.ts +3 -3
- package/dist/extensions/command-suggest.js +4 -3
- package/dist/extensions/index.d.ts +19 -0
- package/dist/extensions/index.js +25 -0
- package/dist/extensions/slash-commands.d.ts +1 -1
- package/dist/extensions/slash-commands.js +16 -1
- package/dist/extensions/terminal-buffer.d.ts +1 -1
- package/dist/extensions/terminal-buffer.js +13 -4
- package/dist/extensions/tui-renderer.js +63 -43
- package/dist/index.js +14 -20
- package/dist/settings.d.ts +6 -0
- package/dist/settings.js +4 -1
- package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +1 -1
- package/dist/{input-handler.js → shell/input-handler.js} +60 -43
- package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
- package/dist/{output-parser.js → shell/output-parser.js} +1 -1
- package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
- package/dist/{shell.js → shell/shell.js} +20 -6
- package/dist/types.d.ts +49 -10
- package/dist/utils/compositor.d.ts +62 -0
- package/dist/utils/compositor.js +88 -0
- package/dist/utils/diff-renderer.js +92 -4
- package/dist/utils/floating-panel.d.ts +2 -0
- package/dist/utils/floating-panel.js +30 -14
- package/dist/utils/handler-registry.d.ts +26 -10
- package/dist/utils/handler-registry.js +52 -16
- package/dist/utils/line-editor.d.ts +23 -3
- package/dist/utils/line-editor.js +180 -42
- package/dist/utils/markdown.d.ts +1 -0
- package/dist/utils/markdown.js +1 -1
- package/dist/utils/message-utils.d.ts +35 -0
- package/dist/utils/message-utils.js +75 -0
- package/dist/utils/terminal-buffer.d.ts +5 -1
- package/dist/utils/terminal-buffer.js +18 -2
- package/dist/utils/tool-interactive.d.ts +12 -0
- package/dist/utils/tool-interactive.js +53 -0
- package/examples/extensions/ash-acp-bridge/README.md +39 -0
- package/examples/extensions/ash-acp-bridge/package.json +23 -0
- package/examples/extensions/ash-acp-bridge/src/index.ts +571 -0
- package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
- package/examples/extensions/ash-mcp-bridge/README.md +72 -0
- package/examples/extensions/ash-mcp-bridge/index.ts +154 -0
- package/examples/extensions/ash-mcp-bridge/package.json +9 -0
- package/examples/extensions/interactive-prompts.ts +82 -110
- package/examples/extensions/overlay-agent.ts +84 -38
- package/examples/extensions/peer-mesh.ts +450 -0
- package/examples/extensions/questionnaire.ts +249 -0
- package/examples/extensions/tmux-pane.ts +307 -0
- package/examples/extensions/web-access.ts +327 -0
- package/package.json +9 -1
- package/dist/extensions/overlay-agent.d.ts +0 -14
- package/dist/extensions/overlay-agent.js +0 -147
- package/examples/extensions/terminal-buffer.ts +0 -184
- /package/dist/{token-budget.d.ts → agent/token-budget.d.ts} +0 -0
|
@@ -78,10 +78,11 @@ export function toNuclearEntries(messages, startSeq, instanceId) {
|
|
|
78
78
|
// ── Formatting ────────────────────────────────────────────────────
|
|
79
79
|
/** Format a nuclear entry as a display line (for in-context injection). */
|
|
80
80
|
export function formatNuclearLine(entry) {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
const d = new Date(entry.ts);
|
|
82
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
83
|
+
// ISO-ish compact: 2026-04-13 14:05
|
|
84
|
+
const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
85
|
+
return `#${entry.seq} [${stamp}] ${entry.sum}`;
|
|
85
86
|
}
|
|
86
87
|
// ── Serialization (JSONL for history file) ────────────────────────
|
|
87
88
|
/** Serialize a nuclear entry to a JSONL line. */
|
|
@@ -1,14 +1,13 @@
|
|
|
1
|
-
import type { ToolDefinition } from "./types.js";
|
|
2
1
|
import type { ContextManager } from "../context-manager.js";
|
|
3
2
|
/**
|
|
4
3
|
* Static system prompt — identical across all queries, cacheable.
|
|
5
4
|
* Contains only identity and behavioral instructions.
|
|
6
5
|
*/
|
|
7
|
-
export declare const STATIC_SYSTEM_PROMPT
|
|
6
|
+
export declare const STATIC_SYSTEM_PROMPT: string;
|
|
8
7
|
/**
|
|
9
8
|
* Build the dynamic context — injected as a user message before each query.
|
|
10
|
-
* Contains everything that changes:
|
|
9
|
+
* Contains everything that changes: shell context, conventions, cwd.
|
|
11
10
|
*
|
|
12
|
-
* Runs through the "
|
|
11
|
+
* Runs through the "dynamic-context:build" handler so extensions can advise.
|
|
13
12
|
*/
|
|
14
|
-
export declare function buildDynamicContext(
|
|
13
|
+
export declare function buildDynamicContext(contextManager: ContextManager, shellBudgetTokens?: number): string;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
3
4
|
import { discoverSkills } from "./skills.js";
|
|
5
|
+
/** Resolve the absolute path to agent-sh's own docs directory. */
|
|
6
|
+
const DOCS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../docs");
|
|
4
7
|
/** File names to scan for project conventions (checked in order). */
|
|
5
8
|
const CONVENTION_FILES = ["CLAUDE.md", "AGENT.md"];
|
|
6
9
|
/**
|
|
@@ -36,7 +39,7 @@ function loadConventionFiles(dir) {
|
|
|
36
39
|
* Static system prompt — identical across all queries, cacheable.
|
|
37
40
|
* Contains only identity and behavioral instructions.
|
|
38
41
|
*/
|
|
39
|
-
export const STATIC_SYSTEM_PROMPT = `You are an AI coding assistant embedded in agent-sh, a terminal shell.
|
|
42
|
+
export const STATIC_SYSTEM_PROMPT = `You are ash, an AI coding assistant embedded in agent-sh, a terminal shell.
|
|
40
43
|
You have access to the user's shell environment and can read, write, and execute code.
|
|
41
44
|
You share the user's working directory, environment variables, and shell history.
|
|
42
45
|
|
|
@@ -62,46 +65,27 @@ Use this to run complete, non-interactive commands in the user's real shell. Use
|
|
|
62
65
|
- Any command where the user wants real side effects
|
|
63
66
|
- Set return_output=true only if you need to inspect the result
|
|
64
67
|
|
|
65
|
-
**Terminal interaction** (terminal_read, terminal_keys):
|
|
66
|
-
Use these to observe and interact with what is currently on the user's terminal screen.
|
|
67
|
-
- terminal_read: see what the user sees (current screen contents, cursor position)
|
|
68
|
-
- terminal_keys: send keystrokes as if the user typed them
|
|
69
|
-
Use for: driving interactive programs (vim, htop, less, ssh, REPLs), answering questions
|
|
70
|
-
about what's on screen, or typing at the shell prompt when a program is already running.
|
|
71
|
-
Do NOT use user_shell to interact with an already-running program — use these instead.
|
|
72
|
-
|
|
73
68
|
Default to scratchpad tools for your own investigation. Use display when the
|
|
74
69
|
user is the intended audience. Use user_shell when the command has real effects.
|
|
75
|
-
Use terminal_read/terminal_keys when interacting with what's already on screen.
|
|
76
|
-
|
|
77
|
-
# Interactive Overlay Sessions
|
|
78
|
-
|
|
79
|
-
When the dynamic context includes \`interactive-session: true\`, the user has summoned you
|
|
80
|
-
via a hotkey overlay from inside their live terminal. They may be in the middle of using
|
|
81
|
-
a program (vim, ssh, a REPL, etc.) or at a shell prompt. In this mode:
|
|
82
|
-
- Start with terminal_read if you need to understand what's on screen.
|
|
83
|
-
- Prefer terminal_keys to interact with whatever is currently running.
|
|
84
|
-
- Use user_shell only for running new, standalone commands — not for interacting with
|
|
85
|
-
what's already on screen.
|
|
86
|
-
- Keep responses concise — the user is in the middle of a workflow.
|
|
87
70
|
|
|
88
71
|
# Tool Usage Guidelines
|
|
89
72
|
- Use read_file before editing a file you haven't seen
|
|
90
73
|
- Prefer edit_file over write_file for modifying existing files
|
|
91
74
|
- Use grep/glob to find files before reading them
|
|
92
75
|
- Keep bash commands focused; avoid long-running blocking commands
|
|
93
|
-
- Always check command exit codes for errors
|
|
76
|
+
- Always check command exit codes for errors
|
|
77
|
+
|
|
78
|
+
# Documentation
|
|
79
|
+
agent-sh documentation is available in: ${DOCS_DIR}
|
|
80
|
+
Use read_file on ${DOCS_DIR}/README.md for an index of all docs.`;
|
|
94
81
|
/**
|
|
95
82
|
* Build the dynamic context — injected as a user message before each query.
|
|
96
|
-
* Contains everything that changes:
|
|
83
|
+
* Contains everything that changes: shell context, conventions, cwd.
|
|
97
84
|
*
|
|
98
|
-
* Runs through the "
|
|
85
|
+
* Runs through the "dynamic-context:build" handler so extensions can advise.
|
|
99
86
|
*/
|
|
100
|
-
export function buildDynamicContext(
|
|
87
|
+
export function buildDynamicContext(contextManager, shellBudgetTokens) {
|
|
101
88
|
const sections = [];
|
|
102
|
-
// Tools
|
|
103
|
-
sections.push("# Available Tools\n" +
|
|
104
|
-
tools.map((t) => `- ${t.name}: ${t.description}`).join("\n"));
|
|
105
89
|
// Project conventions (CLAUDE.md / AGENT.md)
|
|
106
90
|
const conventions = loadConventionFiles(contextManager.getCwd());
|
|
107
91
|
if (conventions.length > 0) {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* The budget accounts for fixed overhead (system prompt, tool definitions,
|
|
9
9
|
* response reserve) and divides the remaining space by a configurable ratio.
|
|
10
10
|
*/
|
|
11
|
-
import { getSettings } from "
|
|
11
|
+
import { getSettings } from "../settings.js";
|
|
12
12
|
/** Overhead estimates (tokens). */
|
|
13
13
|
const SYSTEM_PROMPT_OVERHEAD = 800;
|
|
14
14
|
const DYNAMIC_CONTEXT_OVERHEAD = 500; // conventions, metadata, skills list
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ToolProtocol — abstracts how tools are presented to the LLM and how
|
|
3
|
+
* tool calls are parsed from responses.
|
|
4
|
+
*
|
|
5
|
+
* Two modes:
|
|
6
|
+
* "api" — tools sent via OpenAI tools param, parsed from delta.tool_calls
|
|
7
|
+
* "inline" — tools described as text, tool calls are JSON code blocks
|
|
8
|
+
*
|
|
9
|
+
* The agent loop uses this interface uniformly so the rest of the code
|
|
10
|
+
* doesn't need to know which mode is active.
|
|
11
|
+
*/
|
|
12
|
+
import type { ChatCompletionTool } from "../utils/llm-client.js";
|
|
13
|
+
import type { ToolDefinition } from "./types.js";
|
|
14
|
+
import type { ConversationState } from "./conversation-state.js";
|
|
15
|
+
export interface PendingToolCall {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
argumentsJson: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ToolResult {
|
|
21
|
+
callId: string;
|
|
22
|
+
toolName: string;
|
|
23
|
+
content: string;
|
|
24
|
+
isError: boolean;
|
|
25
|
+
}
|
|
26
|
+
/** Streaming filter — strips tool calls from display output. */
|
|
27
|
+
export interface StreamFilter {
|
|
28
|
+
feed(chunk: string): string;
|
|
29
|
+
flush(): string;
|
|
30
|
+
}
|
|
31
|
+
export interface ToolProtocol {
|
|
32
|
+
readonly mode: string;
|
|
33
|
+
/** Tools to pass in the API request's `tools` parameter. undefined = omit. */
|
|
34
|
+
getApiTools(tools: ToolDefinition[]): ChatCompletionTool[] | undefined;
|
|
35
|
+
/** Extra text for dynamic context (tool catalog for inline mode). */
|
|
36
|
+
getToolPrompt(tools: ToolDefinition[]): string;
|
|
37
|
+
/** Extract tool calls from a completed response. */
|
|
38
|
+
extractToolCalls(responseText: string, streamedCalls: PendingToolCall[]): PendingToolCall[];
|
|
39
|
+
/** Rewrite a tool call before execution (e.g., unwrap meta-tool). */
|
|
40
|
+
rewriteToolCall(tc: PendingToolCall): PendingToolCall;
|
|
41
|
+
/** Record the assistant turn in conversation state. */
|
|
42
|
+
recordAssistant(conv: ConversationState, text: string, toolCalls: PendingToolCall[]): void;
|
|
43
|
+
/** Record all tool results for a batch as conversation messages. */
|
|
44
|
+
recordResults(conv: ConversationState, results: ToolResult[]): void;
|
|
45
|
+
/** Create a stream filter for stripping tool calls from display. null = pass-through. */
|
|
46
|
+
createStreamFilter(toolNames: string[]): StreamFilter | null;
|
|
47
|
+
}
|
|
48
|
+
export declare class ApiToolProtocol implements ToolProtocol {
|
|
49
|
+
readonly mode: "api";
|
|
50
|
+
getApiTools(tools: ToolDefinition[]): ChatCompletionTool[] | undefined;
|
|
51
|
+
getToolPrompt(): string;
|
|
52
|
+
extractToolCalls(_text: string, streamedCalls: PendingToolCall[]): PendingToolCall[];
|
|
53
|
+
rewriteToolCall(tc: PendingToolCall): PendingToolCall;
|
|
54
|
+
recordAssistant(conv: ConversationState, text: string, toolCalls: PendingToolCall[]): void;
|
|
55
|
+
recordResults(conv: ConversationState, results: ToolResult[]): void;
|
|
56
|
+
createStreamFilter(): null;
|
|
57
|
+
}
|
|
58
|
+
export declare class InlineToolProtocol implements ToolProtocol {
|
|
59
|
+
readonly mode: "inline";
|
|
60
|
+
private callCounter;
|
|
61
|
+
getApiTools(): undefined;
|
|
62
|
+
getToolPrompt(tools: ToolDefinition[]): string;
|
|
63
|
+
rewriteToolCall(tc: PendingToolCall): PendingToolCall;
|
|
64
|
+
extractToolCalls(text: string, _streamedCalls: PendingToolCall[]): PendingToolCall[];
|
|
65
|
+
recordAssistant(conv: ConversationState, text: string, _toolCalls: PendingToolCall[]): void;
|
|
66
|
+
recordResults(conv: ConversationState, results: ToolResult[]): void;
|
|
67
|
+
createStreamFilter(_toolNames: string[]): StreamFilter;
|
|
68
|
+
}
|
|
69
|
+
export declare class DeferredToolProtocol implements ToolProtocol {
|
|
70
|
+
readonly mode: "deferred";
|
|
71
|
+
private coreNames;
|
|
72
|
+
/** Cached extension tool schemas for arg validation. */
|
|
73
|
+
private extSchemas;
|
|
74
|
+
constructor(coreNames: string[]);
|
|
75
|
+
getApiTools(tools: ToolDefinition[]): ChatCompletionTool[] | undefined;
|
|
76
|
+
getToolPrompt(): string;
|
|
77
|
+
extractToolCalls(_text: string, streamedCalls: PendingToolCall[]): PendingToolCall[];
|
|
78
|
+
rewriteToolCall(tc: PendingToolCall): PendingToolCall;
|
|
79
|
+
recordAssistant(conv: ConversationState, text: string, toolCalls: PendingToolCall[]): void;
|
|
80
|
+
recordResults(conv: ConversationState, results: ToolResult[]): void;
|
|
81
|
+
createStreamFilter(): null;
|
|
82
|
+
}
|
|
83
|
+
export declare function createToolProtocol(mode: "api" | "inline" | "deferred"): ToolProtocol;
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// ── API mode (current behavior) ──────────────────────────────────
|
|
2
|
+
export class ApiToolProtocol {
|
|
3
|
+
mode = "api";
|
|
4
|
+
getApiTools(tools) {
|
|
5
|
+
if (tools.length === 0)
|
|
6
|
+
return undefined;
|
|
7
|
+
return tools.map((t) => ({
|
|
8
|
+
type: "function",
|
|
9
|
+
function: {
|
|
10
|
+
name: t.name,
|
|
11
|
+
description: t.description,
|
|
12
|
+
parameters: t.input_schema,
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
16
|
+
getToolPrompt() {
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
extractToolCalls(_text, streamedCalls) {
|
|
20
|
+
return streamedCalls;
|
|
21
|
+
}
|
|
22
|
+
rewriteToolCall(tc) {
|
|
23
|
+
return tc;
|
|
24
|
+
}
|
|
25
|
+
recordAssistant(conv, text, toolCalls) {
|
|
26
|
+
const calls = toolCalls.length
|
|
27
|
+
? toolCalls.map((tc) => ({
|
|
28
|
+
id: tc.id,
|
|
29
|
+
function: { name: tc.name, arguments: tc.argumentsJson },
|
|
30
|
+
}))
|
|
31
|
+
: undefined;
|
|
32
|
+
conv.addAssistantMessage(text || null, calls);
|
|
33
|
+
}
|
|
34
|
+
recordResults(conv, results) {
|
|
35
|
+
for (const r of results) {
|
|
36
|
+
const content = r.isError ? `Error: ${r.content}` : r.content;
|
|
37
|
+
conv.addToolResult(r.callId, content);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
createStreamFilter() {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// ── Inline mode (JSON code block tool calls) ─────────────────────
|
|
45
|
+
export class InlineToolProtocol {
|
|
46
|
+
mode = "inline";
|
|
47
|
+
callCounter = 0;
|
|
48
|
+
getApiTools() {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
getToolPrompt(tools) {
|
|
52
|
+
if (tools.length === 0)
|
|
53
|
+
return "";
|
|
54
|
+
const lines = [
|
|
55
|
+
"",
|
|
56
|
+
"# Tools",
|
|
57
|
+
"",
|
|
58
|
+
"To call a tool, write a ```tool fenced block with JSON:",
|
|
59
|
+
"",
|
|
60
|
+
"```tool",
|
|
61
|
+
'{"tool": "grep", "pattern": "TODO", "path": "src/"}',
|
|
62
|
+
"```",
|
|
63
|
+
"",
|
|
64
|
+
"The `tool` field selects which tool. All other fields are arguments.",
|
|
65
|
+
"Multiple tool blocks allowed per response.",
|
|
66
|
+
"",
|
|
67
|
+
"Available: " + tools.map((t) => `${t.name}${formatParams(t.input_schema)}`).join(", "),
|
|
68
|
+
];
|
|
69
|
+
return lines.join("\n");
|
|
70
|
+
}
|
|
71
|
+
rewriteToolCall(tc) {
|
|
72
|
+
return tc;
|
|
73
|
+
}
|
|
74
|
+
extractToolCalls(text, _streamedCalls) {
|
|
75
|
+
const calls = [];
|
|
76
|
+
// Match ```tool ... ``` blocks
|
|
77
|
+
const regex = /```tool\s*\n([\s\S]*?)```/g;
|
|
78
|
+
let match;
|
|
79
|
+
while ((match = regex.exec(text)) !== null) {
|
|
80
|
+
const body = match[1].trim();
|
|
81
|
+
try {
|
|
82
|
+
const obj = JSON.parse(body);
|
|
83
|
+
const name = obj.tool;
|
|
84
|
+
if (typeof name !== "string")
|
|
85
|
+
continue;
|
|
86
|
+
// Separate tool name from args
|
|
87
|
+
const { tool: _, ...args } = obj;
|
|
88
|
+
calls.push({
|
|
89
|
+
id: `inline_${++this.callCounter}`,
|
|
90
|
+
name,
|
|
91
|
+
argumentsJson: JSON.stringify(args),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Not valid JSON — skip
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return calls;
|
|
99
|
+
}
|
|
100
|
+
recordAssistant(conv, text, _toolCalls) {
|
|
101
|
+
conv.addAssistantMessage(text || null);
|
|
102
|
+
}
|
|
103
|
+
recordResults(conv, results) {
|
|
104
|
+
if (results.length === 0)
|
|
105
|
+
return;
|
|
106
|
+
const parts = results.map((r) => {
|
|
107
|
+
const status = r.isError ? "error" : "ok";
|
|
108
|
+
return `[${r.toolName} ${r.callId} ${status}]\n${r.content}`;
|
|
109
|
+
});
|
|
110
|
+
conv.addToolResultInline(parts.join("\n\n"));
|
|
111
|
+
}
|
|
112
|
+
createStreamFilter(_toolNames) {
|
|
113
|
+
return new CodeBlockFilter();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// ── Code block stream filter ────────────────────────────────────
|
|
117
|
+
/**
|
|
118
|
+
* Strips ```tool ... ``` blocks from streamed text.
|
|
119
|
+
* Simple state machine: normal → in_fence → normal.
|
|
120
|
+
*/
|
|
121
|
+
class CodeBlockFilter {
|
|
122
|
+
buf = "";
|
|
123
|
+
inFence = false;
|
|
124
|
+
lastEmittedNewlines = 0; // track trailing newlines to collapse blanks
|
|
125
|
+
feed(chunk) {
|
|
126
|
+
this.buf += chunk;
|
|
127
|
+
let raw = "";
|
|
128
|
+
while (this.buf.length > 0) {
|
|
129
|
+
if (this.inFence) {
|
|
130
|
+
// Look for closing ```
|
|
131
|
+
const closeIdx = this.buf.indexOf("```");
|
|
132
|
+
if (closeIdx !== -1) {
|
|
133
|
+
// Skip past closing ``` and any trailing whitespace on that line
|
|
134
|
+
let end = closeIdx + 3;
|
|
135
|
+
while (end < this.buf.length && this.buf[end] === "\n")
|
|
136
|
+
end++;
|
|
137
|
+
this.buf = this.buf.slice(end);
|
|
138
|
+
this.inFence = false;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
// No closing yet — keep buffering
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
// Look for opening ```tool
|
|
145
|
+
const openIdx = this.buf.indexOf("```tool");
|
|
146
|
+
if (openIdx !== -1) {
|
|
147
|
+
// Emit everything before the fence, trimming trailing newline
|
|
148
|
+
let before = this.buf.slice(0, openIdx);
|
|
149
|
+
if (before.endsWith("\n"))
|
|
150
|
+
before = before.slice(0, -1);
|
|
151
|
+
raw += before;
|
|
152
|
+
this.buf = this.buf.slice(openIdx + 7); // skip ```tool
|
|
153
|
+
this.inFence = true;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
// Stray ``` on its own line (residual closing fence)
|
|
157
|
+
const strayIdx = this.buf.indexOf("```");
|
|
158
|
+
if (strayIdx !== -1) {
|
|
159
|
+
// Check if it's just backticks on a line (possibly with whitespace)
|
|
160
|
+
const lineStart = this.buf.lastIndexOf("\n", strayIdx - 1) + 1;
|
|
161
|
+
const lineEnd = this.buf.indexOf("\n", strayIdx);
|
|
162
|
+
const line = this.buf.slice(lineStart, lineEnd === -1 ? undefined : lineEnd).trim();
|
|
163
|
+
if (line === "```") {
|
|
164
|
+
raw += this.buf.slice(0, lineStart);
|
|
165
|
+
this.buf = this.buf.slice(lineEnd === -1 ? this.buf.length : lineEnd + 1);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Could be a partial match at the end
|
|
170
|
+
const marker = "```tool";
|
|
171
|
+
let partial = false;
|
|
172
|
+
for (let i = Math.min(marker.length - 1, this.buf.length); i >= 1; i--) {
|
|
173
|
+
if (this.buf.endsWith(marker.slice(0, i))) {
|
|
174
|
+
raw += this.buf.slice(0, this.buf.length - i);
|
|
175
|
+
this.buf = this.buf.slice(this.buf.length - i);
|
|
176
|
+
partial = true;
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (partial)
|
|
181
|
+
break;
|
|
182
|
+
// No fence anywhere — emit all
|
|
183
|
+
raw += this.buf;
|
|
184
|
+
this.buf = "";
|
|
185
|
+
}
|
|
186
|
+
// Collapse runs of 3+ newlines into 2 (one blank line max)
|
|
187
|
+
return this.collapseNewlines(raw);
|
|
188
|
+
}
|
|
189
|
+
flush() {
|
|
190
|
+
const out = this.collapseNewlines(this.buf);
|
|
191
|
+
this.buf = "";
|
|
192
|
+
this.inFence = false;
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
collapseNewlines(text) {
|
|
196
|
+
if (!text)
|
|
197
|
+
return text;
|
|
198
|
+
// Count leading newlines and merge with trailing from last emit
|
|
199
|
+
let i = 0;
|
|
200
|
+
while (i < text.length && text[i] === "\n")
|
|
201
|
+
i++;
|
|
202
|
+
const leading = i;
|
|
203
|
+
const totalNewlines = this.lastEmittedNewlines + leading;
|
|
204
|
+
// Allow at most 2 consecutive newlines
|
|
205
|
+
let prefix = "";
|
|
206
|
+
if (leading > 0) {
|
|
207
|
+
const allowed = Math.max(0, 2 - this.lastEmittedNewlines);
|
|
208
|
+
prefix = "\n".repeat(Math.min(leading, allowed));
|
|
209
|
+
text = text.slice(leading);
|
|
210
|
+
}
|
|
211
|
+
// Collapse internal runs
|
|
212
|
+
text = text.replace(/\n{3,}/g, "\n\n");
|
|
213
|
+
// Track trailing newlines for next call
|
|
214
|
+
let trailing = 0;
|
|
215
|
+
let j = text.length;
|
|
216
|
+
while (j > 0 && text[j - 1] === "\n") {
|
|
217
|
+
j--;
|
|
218
|
+
trailing++;
|
|
219
|
+
}
|
|
220
|
+
this.lastEmittedNewlines = trailing > 0 ? trailing : (prefix ? totalNewlines - leading + prefix.length : 0);
|
|
221
|
+
return prefix + text;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
225
|
+
function formatParams(schema) {
|
|
226
|
+
const props = schema.properties;
|
|
227
|
+
if (!props || Object.keys(props).length === 0)
|
|
228
|
+
return "()";
|
|
229
|
+
const required = new Set(schema.required ?? []);
|
|
230
|
+
const params = Object.entries(props).map(([name, prop]) => {
|
|
231
|
+
const opt = required.has(name) ? "" : "?";
|
|
232
|
+
const enumVals = prop.enum;
|
|
233
|
+
if (enumVals)
|
|
234
|
+
return `${name}${opt}: ${enumVals.join("|")}`;
|
|
235
|
+
return `${name}${opt}`;
|
|
236
|
+
});
|
|
237
|
+
return `(${params.join(", ")})`;
|
|
238
|
+
}
|
|
239
|
+
// ── Deferred mode (core tools full schema, extensions via meta-tool) ──
|
|
240
|
+
const META_TOOL_NAME = "use_extension";
|
|
241
|
+
export class DeferredToolProtocol {
|
|
242
|
+
mode = "deferred";
|
|
243
|
+
coreNames;
|
|
244
|
+
/** Cached extension tool schemas for arg validation. */
|
|
245
|
+
extSchemas = new Map();
|
|
246
|
+
constructor(coreNames) {
|
|
247
|
+
this.coreNames = new Set(coreNames);
|
|
248
|
+
}
|
|
249
|
+
getApiTools(tools) {
|
|
250
|
+
const core = tools.filter((t) => this.coreNames.has(t.name));
|
|
251
|
+
const ext = tools.filter((t) => !this.coreNames.has(t.name));
|
|
252
|
+
// Cache extension schemas for validation in rewriteToolCall
|
|
253
|
+
this.extSchemas.clear();
|
|
254
|
+
for (const t of ext) {
|
|
255
|
+
this.extSchemas.set(t.name, t.input_schema);
|
|
256
|
+
}
|
|
257
|
+
const apiTools = core.map((t) => ({
|
|
258
|
+
type: "function",
|
|
259
|
+
function: {
|
|
260
|
+
name: t.name,
|
|
261
|
+
description: t.description,
|
|
262
|
+
parameters: t.input_schema,
|
|
263
|
+
},
|
|
264
|
+
}));
|
|
265
|
+
if (ext.length > 0) {
|
|
266
|
+
const catalog = ext
|
|
267
|
+
.map((t) => `${t.name}${formatParams(t.input_schema)}`)
|
|
268
|
+
.join(", ");
|
|
269
|
+
apiTools.push({
|
|
270
|
+
type: "function",
|
|
271
|
+
function: {
|
|
272
|
+
name: META_TOOL_NAME,
|
|
273
|
+
description: `Call an extension tool. Available: ${catalog}`,
|
|
274
|
+
parameters: {
|
|
275
|
+
type: "object",
|
|
276
|
+
properties: {
|
|
277
|
+
name: { type: "string", description: "Tool name to call" },
|
|
278
|
+
args: {
|
|
279
|
+
type: "object",
|
|
280
|
+
description: "Tool arguments",
|
|
281
|
+
properties: {},
|
|
282
|
+
additionalProperties: true,
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
required: ["name"],
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return apiTools.length > 0 ? apiTools : undefined;
|
|
291
|
+
}
|
|
292
|
+
getToolPrompt() {
|
|
293
|
+
return "";
|
|
294
|
+
}
|
|
295
|
+
extractToolCalls(_text, streamedCalls) {
|
|
296
|
+
return streamedCalls;
|
|
297
|
+
}
|
|
298
|
+
rewriteToolCall(tc) {
|
|
299
|
+
if (tc.name !== META_TOOL_NAME)
|
|
300
|
+
return tc;
|
|
301
|
+
// Unwrap: use_extension(name="foo", args={...}) → foo({...})
|
|
302
|
+
try {
|
|
303
|
+
const parsed = JSON.parse(tc.argumentsJson);
|
|
304
|
+
const targetName = parsed.name;
|
|
305
|
+
const targetArgs = (parsed.args ?? {});
|
|
306
|
+
// Validate: does the extension exist?
|
|
307
|
+
const schema = this.extSchemas.get(targetName);
|
|
308
|
+
if (!schema) {
|
|
309
|
+
const available = [...this.extSchemas.keys()].join(", ");
|
|
310
|
+
return {
|
|
311
|
+
id: tc.id,
|
|
312
|
+
name: META_TOOL_NAME,
|
|
313
|
+
argumentsJson: JSON.stringify({
|
|
314
|
+
_error: `Unknown extension "${targetName}". Available: ${available}`,
|
|
315
|
+
}),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
// Validate: check for unknown/missing params against schema
|
|
319
|
+
const schemaProps = schema.properties;
|
|
320
|
+
const requiredParams = new Set(schema.required ?? []);
|
|
321
|
+
if (schemaProps) {
|
|
322
|
+
const validParams = new Set(Object.keys(schemaProps));
|
|
323
|
+
const providedParams = Object.keys(targetArgs);
|
|
324
|
+
// Check for unknown params (likely wrong names)
|
|
325
|
+
const unknown = providedParams.filter((p) => !validParams.has(p));
|
|
326
|
+
// Check for missing required params
|
|
327
|
+
const missing = [...requiredParams].filter((p) => !targetArgs[p]);
|
|
328
|
+
if (unknown.length > 0 || missing.length > 0) {
|
|
329
|
+
const expected = [...validParams]
|
|
330
|
+
.map((p) => `${p}${requiredParams.has(p) ? " (required)" : ""}`)
|
|
331
|
+
.join(", ");
|
|
332
|
+
let hint = `Wrong arguments for "${targetName}". Expected params: ${expected}.`;
|
|
333
|
+
if (unknown.length > 0)
|
|
334
|
+
hint += ` Unknown: ${unknown.join(", ")}.`;
|
|
335
|
+
if (missing.length > 0)
|
|
336
|
+
hint += ` Missing: ${missing.join(", ")}.`;
|
|
337
|
+
return {
|
|
338
|
+
id: tc.id,
|
|
339
|
+
name: META_TOOL_NAME,
|
|
340
|
+
argumentsJson: JSON.stringify({ _error: hint }),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
id: tc.id,
|
|
346
|
+
name: targetName,
|
|
347
|
+
argumentsJson: JSON.stringify(targetArgs),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return tc; // Let it fail naturally downstream
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
recordAssistant(conv, text, toolCalls) {
|
|
355
|
+
const calls = toolCalls.length
|
|
356
|
+
? toolCalls.map((tc) => ({
|
|
357
|
+
id: tc.id,
|
|
358
|
+
function: { name: tc.name, arguments: tc.argumentsJson },
|
|
359
|
+
}))
|
|
360
|
+
: undefined;
|
|
361
|
+
conv.addAssistantMessage(text || null, calls);
|
|
362
|
+
}
|
|
363
|
+
recordResults(conv, results) {
|
|
364
|
+
for (const r of results) {
|
|
365
|
+
const content = r.isError ? `Error: ${r.content}` : r.content;
|
|
366
|
+
conv.addToolResult(r.callId, content);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
createStreamFilter() {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// ── Factory ─────────────────────────────────────────────────────
|
|
374
|
+
/** Core tool names — always sent with full schema. */
|
|
375
|
+
const CORE_TOOLS = [
|
|
376
|
+
"bash", "read_file", "write_file", "edit_file",
|
|
377
|
+
"grep", "glob", "ls", "user_shell", "display",
|
|
378
|
+
"list_skills", "conversation_recall",
|
|
379
|
+
];
|
|
380
|
+
export function createToolProtocol(mode) {
|
|
381
|
+
if (mode === "inline")
|
|
382
|
+
return new InlineToolProtocol();
|
|
383
|
+
if (mode === "deferred")
|
|
384
|
+
return new DeferredToolProtocol(CORE_TOOLS);
|
|
385
|
+
return new ApiToolProtocol();
|
|
386
|
+
}
|
package/dist/agent/types.d.ts
CHANGED
|
@@ -47,13 +47,33 @@ export interface ToolDisplayInfo {
|
|
|
47
47
|
* icon + detail only. When absent, the tool name is shown alongside the detail. */
|
|
48
48
|
icon?: string;
|
|
49
49
|
}
|
|
50
|
+
/** Interactive UI session — imperative control over rendering + input. */
|
|
51
|
+
export interface InteractiveSession<T> {
|
|
52
|
+
/** Return lines to render. Called on mount and after each input. */
|
|
53
|
+
render(width: number): string[];
|
|
54
|
+
/** Handle raw input. Call done(result) to finish the session. */
|
|
55
|
+
handleInput(data: string, done: (result: T) => void): void;
|
|
56
|
+
/** Called when session starts. Receives invalidate() for async re-renders. */
|
|
57
|
+
onMount?(invalidate: () => void): void;
|
|
58
|
+
/** Called when session ends (cleanup). */
|
|
59
|
+
onUnmount?(): void;
|
|
60
|
+
}
|
|
61
|
+
/** Interactive UI capability passed to tools during execution. */
|
|
62
|
+
export interface ToolUI {
|
|
63
|
+
/** Present a custom interactive UI and wait for the user's response. */
|
|
64
|
+
custom<T>(session: InteractiveSession<T>): Promise<T>;
|
|
65
|
+
}
|
|
66
|
+
/** Context passed to tool execute() as optional third parameter. */
|
|
67
|
+
export interface ToolExecutionContext {
|
|
68
|
+
ui: ToolUI;
|
|
69
|
+
}
|
|
50
70
|
export interface ToolDefinition {
|
|
51
71
|
name: string;
|
|
52
72
|
/** Short label for TUI display (e.g. "search" instead of "ads_search"). Defaults to name. */
|
|
53
73
|
displayName?: string;
|
|
54
74
|
description: string;
|
|
55
75
|
input_schema: Record<string, unknown>;
|
|
56
|
-
execute(args: Record<string, unknown>, onChunk?: (chunk: string) => void): Promise<ToolResult>;
|
|
76
|
+
execute(args: Record<string, unknown>, onChunk?: (chunk: string) => void, ctx?: ToolExecutionContext): Promise<ToolResult>;
|
|
57
77
|
/** Whether to stream tool output to the TUI (default: true). */
|
|
58
78
|
showOutput?: boolean;
|
|
59
79
|
/** Whether this tool may modify files — triggers file watcher (default: false). */
|
package/dist/core.d.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Core kernel — the minimum viable agent-sh.
|
|
3
3
|
*
|
|
4
|
-
* Wires up EventBus + ContextManager
|
|
4
|
+
* Wires up EventBus + ContextManager without any frontend or agent backend.
|
|
5
5
|
* Consumers attach their own I/O (Shell, WebSocket, REST, tests) by
|
|
6
6
|
* subscribing to bus events.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Agent backends are loaded as extensions and register themselves via
|
|
9
|
+
* the agent:register-backend bus event. The built-in "ash" backend is
|
|
10
|
+
* loaded from src/extensions/agent-backend.ts.
|
|
11
11
|
*
|
|
12
12
|
* Usage:
|
|
13
13
|
* import { createCore } from "agent-sh";
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { EventBus } from "./event-bus.js";
|
|
20
20
|
import { ContextManager } from "./context-manager.js";
|
|
21
|
-
import { LlmClient } from "./utils/llm-client.js";
|
|
22
21
|
import type { AgentShellConfig, ExtensionContext } from "./types.js";
|
|
22
|
+
import { HandlerRegistry } from "./utils/handler-registry.js";
|
|
23
23
|
export { EventBus } from "./event-bus.js";
|
|
24
24
|
export type { ShellEvents } from "./event-bus.js";
|
|
25
25
|
export type { AgentShellConfig, ExtensionContext } from "./types.js";
|
|
@@ -31,8 +31,8 @@ export { LlmClient } from "./utils/llm-client.js";
|
|
|
31
31
|
export interface AgentShellCore {
|
|
32
32
|
bus: EventBus;
|
|
33
33
|
contextManager: ContextManager;
|
|
34
|
-
/**
|
|
35
|
-
|
|
34
|
+
/** Handler registry for define/advise/call. */
|
|
35
|
+
handlers: HandlerRegistry;
|
|
36
36
|
/** Activate the agent backend (call after extensions load). */
|
|
37
37
|
activateBackend(): void;
|
|
38
38
|
/** Convenience: emit agent:submit and await the response. */
|