@aion0/forge 0.10.20 → 0.10.23
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/RELEASE_NOTES.md +22 -4
- package/app/api/connectors/route.ts +1 -1
- package/app/api/watches/[id]/route.ts +25 -0
- package/app/api/watches/route.ts +17 -0
- package/app/chat/page.tsx +66 -4
- package/components/Dashboard.tsx +21 -5
- package/components/MonitorPanel.tsx +88 -0
- package/components/WatchesPanel.tsx +97 -0
- package/docs/forge-long-task-watch-design.md +223 -0
- package/docs/tp-automation-api.md +617 -0
- package/lib/browser-bridge-standalone.ts +13 -4
- package/lib/chat/agent-loop.ts +34 -4
- package/lib/chat/bridge-client.ts +2 -2
- package/lib/chat/protocols/ssh.ts +206 -0
- package/lib/chat/tool-dispatcher.ts +60 -5
- package/lib/chat-standalone.ts +12 -0
- package/lib/connectors/types.ts +118 -2
- package/lib/help-docs/21-build-connector.md +42 -0
- package/lib/help-docs/24-watch.md +77 -0
- package/lib/help-docs/CLAUDE.md +2 -0
- package/lib/watch/register.ts +108 -0
- package/lib/watch/start-watch-tool.ts +116 -0
- package/lib/watch/template.ts +40 -0
- package/lib/watch/watch-runner.ts +158 -0
- package/lib/watch/watch-store.ts +218 -0
- package/package.json +1 -1
package/lib/chat/agent-loop.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
import { getMemoryStore } from './memory-store';
|
|
27
27
|
import { buildMemoryContext } from './build-memory-context';
|
|
28
28
|
import { buildMemoryTools } from './memory-tools';
|
|
29
|
+
import { buildStartWatchTool } from '../watch/start-watch-tool';
|
|
29
30
|
import { estimateTokens } from '../memory/token-estimate';
|
|
30
31
|
import {
|
|
31
32
|
listInstalledConnectors,
|
|
@@ -48,10 +49,25 @@ const MAX_TOKENS = 16000;
|
|
|
48
49
|
// and recalled via buildMemoryContext as compact blocks instead.
|
|
49
50
|
const HISTORY_MSG_BUDGET = 60;
|
|
50
51
|
const HISTORY_TOKEN_BUDGET = 8000;
|
|
52
|
+
// Hard cap on a single tool_result stored into the conversation (chars).
|
|
53
|
+
// A giant result (e.g. a connector returning a full test tree) would
|
|
54
|
+
// otherwise blow the whole HISTORY_TOKEN_BUDGET, push its paired
|
|
55
|
+
// assistant tool_use out of the window, and leave an orphan tool_result
|
|
56
|
+
// that trimOrphanToolResults strips — yielding an empty history and an
|
|
57
|
+
// "messages must not be empty" provider error. ~16k chars ≈ 4k tokens,
|
|
58
|
+
// half the budget, so a complete tool_use+result pair always survives.
|
|
59
|
+
const MAX_TOOL_RESULT_CHARS = 16000;
|
|
51
60
|
|
|
52
61
|
// After clipping to last N, the first kept message may be a tool_result
|
|
53
62
|
// whose tool_use was cut. Anthropic/OpenAI both reject that, so drop
|
|
54
63
|
// leading tool_result-bearing user messages until the slice starts clean.
|
|
64
|
+
function truncateToolResult(s: string): string {
|
|
65
|
+
if (s.length <= MAX_TOOL_RESULT_CHARS) return s;
|
|
66
|
+
return s.slice(0, MAX_TOOL_RESULT_CHARS) +
|
|
67
|
+
`\n\n[… tool result truncated: ${s.length} chars total, showing first ${MAX_TOOL_RESULT_CHARS}. ` +
|
|
68
|
+
`Refine the call (filter / paginate / flatten) to get a smaller, complete result.]`;
|
|
69
|
+
}
|
|
70
|
+
|
|
55
71
|
function trimOrphanToolResults(history: Message[]): Message[] {
|
|
56
72
|
let i = 0;
|
|
57
73
|
while (i < history.length) {
|
|
@@ -73,6 +89,7 @@ export interface AgentEvent {
|
|
|
73
89
|
| 'message_saved' // a full message persisted (assistant or tool-results carrier)
|
|
74
90
|
| 'memory_status' // pinned/blocks/hits snapshot from Temper for the UI strip
|
|
75
91
|
| 'turn_done' // loop finished
|
|
92
|
+
| 'watch_status' // ambient background-watch progress (status chip, NOT a message)
|
|
76
93
|
| 'error'; // unrecoverable
|
|
77
94
|
message_id?: string;
|
|
78
95
|
data?: any;
|
|
@@ -308,9 +325,9 @@ function buildConnectorTools(): LlmTool[] {
|
|
|
308
325
|
for (const entry of getConnectorEntries(def)) {
|
|
309
326
|
for (const [toolName, tool] of Object.entries(entry.tools || {})) {
|
|
310
327
|
// Executable if it has a script (browser protocol) OR a non-browser
|
|
311
|
-
// protocol that runs server-side (http / shell).
|
|
328
|
+
// protocol that runs server-side (http / shell / ssh).
|
|
312
329
|
const protocol = (tool as any).protocol;
|
|
313
|
-
const isServerSide = protocol === 'http' || protocol === 'shell';
|
|
330
|
+
const isServerSide = protocol === 'http' || protocol === 'shell' || protocol === 'ssh';
|
|
314
331
|
if (!tool.script && !isServerSide) continue;
|
|
315
332
|
const properties: Record<string, unknown> = {};
|
|
316
333
|
const required: string[] = [];
|
|
@@ -392,6 +409,11 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
392
409
|
const memHandlers: Record<string, BuiltinHandler> = {};
|
|
393
410
|
for (const t of memTools) memHandlers[t.def.name] = t.handle;
|
|
394
411
|
|
|
412
|
+
// start_watch — LLM-driven background watch (always available). Bound
|
|
413
|
+
// to this session so completion reports back here.
|
|
414
|
+
const watchTool = buildStartWatchTool(args.sessionId);
|
|
415
|
+
memHandlers[watchTool.def.name] = watchTool.handle;
|
|
416
|
+
|
|
395
417
|
if (memStore.enabled) {
|
|
396
418
|
// Inspector strip (memory_status event) wants the full inventory —
|
|
397
419
|
// keep its own listBlocks call. The prompt-injection text comes
|
|
@@ -466,6 +488,7 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
466
488
|
const builtinDefsAll = [
|
|
467
489
|
...BUILTIN_TOOL_DEFS,
|
|
468
490
|
...memTools.map((m) => m.def),
|
|
491
|
+
watchTool.def,
|
|
469
492
|
];
|
|
470
493
|
const allTools: LlmTool[] = [
|
|
471
494
|
...builtinDefsAll.map((t) => ({
|
|
@@ -500,6 +523,13 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
500
523
|
const history = trimOrphanToolResults(
|
|
501
524
|
listMessagesCapped(args.sessionId, HISTORY_MSG_BUDGET, HISTORY_TOKEN_BUDGET, estimateTokens),
|
|
502
525
|
);
|
|
526
|
+
// Belt-and-suspenders: tool_result truncation should keep a complete
|
|
527
|
+
// pair in-window, but if history is somehow empty, fail clearly
|
|
528
|
+
// instead of letting the provider throw "messages must not be empty".
|
|
529
|
+
if (history.length === 0) {
|
|
530
|
+
cb({ type: 'error', data: { error: 'Conversation context is empty after trimming an oversized result. Clear the chat or retry with a narrower query.' } });
|
|
531
|
+
return { ok: false, error: 'empty history' };
|
|
532
|
+
}
|
|
503
533
|
|
|
504
534
|
assistantBlocksAccum = [];
|
|
505
535
|
let currentTextBuf = '';
|
|
@@ -543,11 +573,11 @@ export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?:
|
|
|
543
573
|
const toolUses = result.content.filter((b): b is ToolUseBlock => b.type === 'tool_use');
|
|
544
574
|
const toolResults: ToolResultBlock[] = [];
|
|
545
575
|
for (const t of toolUses) {
|
|
546
|
-
const r = await dispatchTool({ id: t.id, name: t.name, input: t.input }, memHandlers);
|
|
576
|
+
const r = await dispatchTool({ id: t.id, name: t.name, input: t.input }, { extraBuiltins: memHandlers, sessionId: args.sessionId });
|
|
547
577
|
const block: ToolResultBlock = {
|
|
548
578
|
type: 'tool_result',
|
|
549
579
|
tool_use_id: t.id,
|
|
550
|
-
content: r.content,
|
|
580
|
+
content: truncateToolResult(r.content),
|
|
551
581
|
is_error: r.is_error,
|
|
552
582
|
};
|
|
553
583
|
toolResults.push(block);
|
|
@@ -13,13 +13,13 @@ const BRIDGE_PORT = Number(process.env.BRIDGE_PORT) || 8407;
|
|
|
13
13
|
interface BridgeRpcOk { ok: true; value: unknown }
|
|
14
14
|
interface BridgeRpcErr { ok: false; error: string }
|
|
15
15
|
|
|
16
|
-
export async function bridgeRpc(method: string, params: unknown): Promise<unknown> {
|
|
16
|
+
export async function bridgeRpc(method: string, params: unknown, timeoutMs?: number): Promise<unknown> {
|
|
17
17
|
let res: Response;
|
|
18
18
|
try {
|
|
19
19
|
res = await fetch(`http://127.0.0.1:${BRIDGE_PORT}/api/rpc`, {
|
|
20
20
|
method: 'POST',
|
|
21
21
|
headers: { 'content-type': 'application/json' },
|
|
22
|
-
body: JSON.stringify({ method, params }),
|
|
22
|
+
body: JSON.stringify({ method, params, ...(timeoutMs ? { timeout_ms: timeoutMs } : {}) }),
|
|
23
23
|
});
|
|
24
24
|
} catch (e) {
|
|
25
25
|
throw new Error(`browser bridge unreachable on port ${BRIDGE_PORT}: ${(e as Error).message}`);
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH protocol runtime for connector tools (`protocol: ssh`).
|
|
3
|
+
*
|
|
4
|
+
* Drives the system `ssh` binary through a PTY (node-pty) so it can
|
|
5
|
+
* handle interactive flows the plain `shell` protocol can't: password
|
|
6
|
+
* auth and mid-command confirmations like `(y/N)`. Built for network
|
|
7
|
+
* devices — e.g. FortiNAC `execute restore image scp …` which prompts
|
|
8
|
+
* twice for `y` then streams a multi-minute restore before rebooting.
|
|
9
|
+
*
|
|
10
|
+
* Declarative, expect-style: the manifest's `ssh` block says what to
|
|
11
|
+
* send, what to auto-answer, the success/failure markers, and which
|
|
12
|
+
* regexes to capture from the transcript. Nothing here is FortiNAC-
|
|
13
|
+
* specific.
|
|
14
|
+
*
|
|
15
|
+
* Safety: connectors are user-installed. An ssh-protocol tool can run
|
|
16
|
+
* arbitrary remote commands — review at install time. The password is
|
|
17
|
+
* fed silently (ssh doesn't echo it) so it never lands in the captured
|
|
18
|
+
* transcript; we also never log it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ConnectorTool, SshSpec } from '../../connectors/types';
|
|
22
|
+
import { expandAllTokens } from '../../plugins/templates';
|
|
23
|
+
import * as pty from 'node-pty';
|
|
24
|
+
|
|
25
|
+
export interface SshProtocolArgs {
|
|
26
|
+
tool: ConnectorTool;
|
|
27
|
+
settings: Record<string, any>;
|
|
28
|
+
args: Record<string, any>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SshProtocolResult {
|
|
32
|
+
content: string;
|
|
33
|
+
is_error?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
37
|
+
const MAX_TIMEOUT_MS = 280_000;
|
|
38
|
+
const MAX_OUTPUT_BYTES = 24 * 1024;
|
|
39
|
+
|
|
40
|
+
function truncate(s: string): string {
|
|
41
|
+
const buf = Buffer.from(s, 'utf-8');
|
|
42
|
+
if (buf.byteLength <= MAX_OUTPUT_BYTES) return s;
|
|
43
|
+
// Keep the tail — the interesting markers (done/reboot) are at the end.
|
|
44
|
+
return `(…truncated, total ${buf.byteLength} bytes)\n` +
|
|
45
|
+
buf.subarray(buf.byteLength - MAX_OUTPUT_BYTES).toString('utf-8');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function rx(pattern: string | undefined): RegExp | null {
|
|
49
|
+
if (!pattern) return null;
|
|
50
|
+
try { return new RegExp(pattern, 'i'); } catch { return null; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve what to actually type for an auto-answer. If the rule's value is
|
|
55
|
+
* the intent `yes`/`no`, pick the token the prompt itself offers — `(yes/no)`
|
|
56
|
+
* → `yes`/`no`, otherwise `y`/`n`. We always send an EXPLICIT token (never
|
|
57
|
+
* rely on the prompt's default: `(y/N)` defaults to N, so "continue" must
|
|
58
|
+
* send `y` outright). Any other value is sent literally.
|
|
59
|
+
*/
|
|
60
|
+
function resolveAnswer(send: string, promptChunk: string): string {
|
|
61
|
+
const intent = String(send || '').trim().toLowerCase();
|
|
62
|
+
if (intent !== 'yes' && intent !== 'no') return send; // literal passthrough
|
|
63
|
+
const offersWords = /\byes\s*\/\s*no\b/i.test(promptChunk);
|
|
64
|
+
if (intent === 'yes') return offersWords ? 'yes' : 'y';
|
|
65
|
+
return offersWords ? 'no' : 'n';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function runSsh({ tool, settings, args }: SshProtocolArgs): Promise<SshProtocolResult> {
|
|
69
|
+
const specRaw = tool.ssh;
|
|
70
|
+
if (!specRaw) return { content: 'ssh tool missing `ssh` block', is_error: true };
|
|
71
|
+
|
|
72
|
+
const exp = (s: string | undefined) => (s == null ? '' : expandAllTokens(String(s), settings, args));
|
|
73
|
+
|
|
74
|
+
const spec: SshSpec = specRaw;
|
|
75
|
+
|
|
76
|
+
// Resolve connection params: chat arg > connector setting > literal in
|
|
77
|
+
// the ssh block > built-in default. (IP comes from chat; port/user/
|
|
78
|
+
// password fall back to the connector's saved defaults.)
|
|
79
|
+
const pickConn = (
|
|
80
|
+
argKeys: string[], settingKey: string, specVal: unknown, dflt: string, secret: boolean,
|
|
81
|
+
): string => {
|
|
82
|
+
for (const k of argKeys) {
|
|
83
|
+
const v = args?.[k];
|
|
84
|
+
if (v != null && String(v) !== '') return secret ? String(v) : String(v).trim();
|
|
85
|
+
}
|
|
86
|
+
const sv = settings?.[settingKey];
|
|
87
|
+
if (sv != null && String(sv) !== '') return secret ? String(sv) : String(sv).trim();
|
|
88
|
+
if (specVal != null && specVal !== '') {
|
|
89
|
+
const r = exp(String(specVal));
|
|
90
|
+
if (r && !r.includes('{')) return secret ? r : r.trim(); // skip unresolved templates
|
|
91
|
+
}
|
|
92
|
+
return dflt;
|
|
93
|
+
};
|
|
94
|
+
const host = pickConn(['host'], 'host', spec.host, '', false);
|
|
95
|
+
const port = pickConn(['port'], 'port', spec.port, '22', false);
|
|
96
|
+
const user = pickConn(['username', 'user'], 'username', spec.user, '', false);
|
|
97
|
+
const password = pickConn(['password'], 'password', spec.password, '', true);
|
|
98
|
+
if (!host) return { content: 'ssh: host is required (pass it from chat, e.g. host=10.15.52.152)', is_error: true };
|
|
99
|
+
if (!user) return { content: 'ssh: user is required (pass username, or set a connector default)', is_error: true };
|
|
100
|
+
|
|
101
|
+
const commands = (spec.commands || []).map((c) => exp(c));
|
|
102
|
+
const autoAnswer = (spec.auto_answer || []).map((r) => ({ re: rx(r.match), send: exp(r.send) }));
|
|
103
|
+
const promptRe = rx(spec.prompt_regex) || /[#$>]\s*$/;
|
|
104
|
+
const doneRe = rx(spec.done_when);
|
|
105
|
+
const failRe = rx(spec.fail_when);
|
|
106
|
+
const passwordRe = /password:\s*$/i;
|
|
107
|
+
const timeoutMs = Math.min(MAX_TIMEOUT_MS, Math.max(2_000, Number(spec.timeout_sec || 0) * 1000 || DEFAULT_TIMEOUT_MS));
|
|
108
|
+
|
|
109
|
+
const sshArgs = [
|
|
110
|
+
'-tt', // force PTY for interactive prompts
|
|
111
|
+
'-p', port,
|
|
112
|
+
'-o', 'StrictHostKeyChecking=accept-new', // no yes/no host-key prompt
|
|
113
|
+
'-o', 'UserKnownHostsFile=/dev/null', // don't pollute known_hosts
|
|
114
|
+
'-o', 'GlobalKnownHostsFile=/dev/null',
|
|
115
|
+
'-o', 'ConnectTimeout=15',
|
|
116
|
+
'-o', 'NumberOfPasswordPrompts=2',
|
|
117
|
+
`${user}@${host}`,
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
return new Promise<SshProtocolResult>((resolve) => {
|
|
121
|
+
let term: pty.IPty;
|
|
122
|
+
try {
|
|
123
|
+
term = pty.spawn('ssh', sshArgs, {
|
|
124
|
+
name: 'xterm-color',
|
|
125
|
+
cols: 200, rows: 50,
|
|
126
|
+
cwd: process.env.HOME || process.cwd(),
|
|
127
|
+
env: process.env as Record<string, string>,
|
|
128
|
+
});
|
|
129
|
+
} catch (e) {
|
|
130
|
+
return resolve({ content: `ssh spawn failed: ${(e as Error).message}`, is_error: true });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let full = '';
|
|
134
|
+
let cmdIndex = 0;
|
|
135
|
+
let pwSent = 0;
|
|
136
|
+
let settled = false;
|
|
137
|
+
const captured: Record<string, string> = {};
|
|
138
|
+
|
|
139
|
+
const finish = (is_error: boolean, note: string) => {
|
|
140
|
+
if (settled) return;
|
|
141
|
+
settled = true;
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
try { term.kill(); } catch {}
|
|
144
|
+
// Run captures over the full transcript.
|
|
145
|
+
if (spec.capture) {
|
|
146
|
+
for (const [name, pat] of Object.entries(spec.capture)) {
|
|
147
|
+
const m = full.match(rx(pat) || /$^/);
|
|
148
|
+
if (m && m[1] != null) captured[name] = m[1];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const payload = {
|
|
152
|
+
ok: !is_error,
|
|
153
|
+
note,
|
|
154
|
+
...(Object.keys(captured).length ? { captured } : {}),
|
|
155
|
+
output_tail: truncate(full).slice(-4000),
|
|
156
|
+
};
|
|
157
|
+
resolve({ content: JSON.stringify(payload), is_error });
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const timer = setTimeout(() => finish(true, `timed out after ${timeoutMs / 1000}s`), timeoutMs);
|
|
161
|
+
|
|
162
|
+
term.onData((chunk: string) => {
|
|
163
|
+
full += chunk;
|
|
164
|
+
// 1) success / failure markers (check on a trailing window so a
|
|
165
|
+
// marker split across chunks still matches).
|
|
166
|
+
const tail = full.slice(-2000);
|
|
167
|
+
if (doneRe && doneRe.test(tail)) return finish(false, 'done marker matched');
|
|
168
|
+
if (failRe && failRe.test(tail)) return finish(true, 'failure marker matched');
|
|
169
|
+
|
|
170
|
+
// 2) password prompt → feed password silently.
|
|
171
|
+
if (password && passwordRe.test(chunk)) {
|
|
172
|
+
if (pwSent >= 2) return finish(true, 'authentication failed (password rejected)');
|
|
173
|
+
pwSent++;
|
|
174
|
+
term.write(`${password}\r`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 3) interactive confirmations — resolve the correct token (y/yes/
|
|
179
|
+
// n/no) from THIS prompt's offered options (intent `yes`/`no`).
|
|
180
|
+
for (const rule of autoAnswer) {
|
|
181
|
+
if (rule.re && rule.re.test(chunk)) {
|
|
182
|
+
term.write(`${resolveAnswer(rule.send, chunk)}\r`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 4) shell prompt → send the next queued command.
|
|
188
|
+
if (promptRe.test(chunk) && cmdIndex < commands.length) {
|
|
189
|
+
const next = commands[cmdIndex++];
|
|
190
|
+
term.write(`${next}\r`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
term.onExit(({ exitCode }) => {
|
|
196
|
+
if (settled) return;
|
|
197
|
+
// Connection closed. Success only if explicitly allowed, or a done
|
|
198
|
+
// marker already landed (covered above). Otherwise treat as error.
|
|
199
|
+
if (spec.success_on_close && cmdIndex >= commands.length) {
|
|
200
|
+
return finish(false, `connection closed (exit ${exitCode})`);
|
|
201
|
+
}
|
|
202
|
+
const sawDone = doneRe ? doneRe.test(full) : false;
|
|
203
|
+
finish(!sawDone, sawDone ? 'done before close' : `connection closed unexpectedly (exit ${exitCode})`);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { bridgeRpc } from './bridge-client';
|
|
13
13
|
import { runHttp } from './protocols/http';
|
|
14
14
|
import { runShell } from './protocols/shell';
|
|
15
|
+
import { runSsh } from './protocols/ssh';
|
|
15
16
|
import {
|
|
16
17
|
getConnector,
|
|
17
18
|
getInstalledConnector,
|
|
@@ -482,6 +483,12 @@ export interface DispatchOptions {
|
|
|
482
483
|
* therefore don't need an LLM-friendly truncation.
|
|
483
484
|
*/
|
|
484
485
|
noTruncation?: boolean;
|
|
486
|
+
/** Chat session that triggered this call — used to register a watch
|
|
487
|
+
* (async tools) bound to the right session for completion callbacks. */
|
|
488
|
+
sessionId?: string;
|
|
489
|
+
/** Remaining chain budget for async watch callbacks. Defaults to the
|
|
490
|
+
* full depth at the top level; decremented when a watch chains a tool. */
|
|
491
|
+
chainDepth?: number;
|
|
485
492
|
}
|
|
486
493
|
|
|
487
494
|
export async function dispatchTool(
|
|
@@ -522,6 +529,18 @@ export async function dispatchTool(
|
|
|
522
529
|
const protocol = located.tool.protocol || 'browser';
|
|
523
530
|
const argInput = (call.input ?? {}) as Record<string, any>;
|
|
524
531
|
|
|
532
|
+
// Apply each parameter's `default` for keys the model omitted, so
|
|
533
|
+
// template tokens like {args.scp_host} resolve instead of staying
|
|
534
|
+
// literal. JSON-schema defaults are only advisory to the model — it
|
|
535
|
+
// routinely drops optional fields — so fill them here. Only sets
|
|
536
|
+
// missing/null; never overrides a value the model actually passed.
|
|
537
|
+
for (const [pname, pdef] of Object.entries(located.tool.parameters || {})) {
|
|
538
|
+
if (pdef && typeof pdef === 'object' && 'default' in (pdef as any)
|
|
539
|
+
&& (argInput[pname] === undefined || argInput[pname] === null)) {
|
|
540
|
+
argInput[pname] = (pdef as any).default;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
525
544
|
// Multi-instance overlay: when a connector's settings carry a
|
|
526
545
|
// `instances` array of `{name, ...}` objects, the tool's `instance`
|
|
527
546
|
// arg picks one and its fields are merged into the top-level settings
|
|
@@ -554,28 +573,64 @@ export async function dispatchTool(
|
|
|
554
573
|
}
|
|
555
574
|
|
|
556
575
|
try {
|
|
576
|
+
let result: ToolResult;
|
|
557
577
|
switch (protocol) {
|
|
558
578
|
case 'http':
|
|
559
|
-
|
|
579
|
+
result = await runHttp({ tool: located.tool, settings: effectiveSettings, args: argInput, connectorAuth: def.auth, noTruncation: opts.noTruncation });
|
|
580
|
+
break;
|
|
560
581
|
case 'shell':
|
|
561
|
-
|
|
582
|
+
result = await runShell({ tool: located.tool, settings: effectiveSettings, args: argInput });
|
|
583
|
+
break;
|
|
584
|
+
case 'ssh':
|
|
585
|
+
result = await runSsh({ tool: located.tool, settings: effectiveSettings, args: argInput });
|
|
586
|
+
break;
|
|
562
587
|
case 'browser': {
|
|
563
588
|
// Hand the whole connector + tool spec + input + settings to the
|
|
564
589
|
// extension's runner.ts via the bridge. The extension keeps owning
|
|
565
590
|
// the runner logic (tab acquire, navigate, executeScript).
|
|
566
591
|
const connector = buildConnectorPayload(def, located.entry, effectiveSettings);
|
|
567
|
-
const
|
|
592
|
+
const r = (await bridgeRpc('connector.run', {
|
|
568
593
|
pluginId: located.connectorId, // wire-name kept for extension
|
|
569
594
|
toolName: located.toolName,
|
|
570
595
|
input: argInput,
|
|
571
596
|
connector,
|
|
572
597
|
settings: effectiveSettings,
|
|
573
|
-
})) as { content?: string; is_error?: boolean } | null;
|
|
574
|
-
|
|
598
|
+
}, located.tool.timeout_ms)) as { content?: string; is_error?: boolean } | null;
|
|
599
|
+
result = { content: r?.content ?? '(no content returned)', is_error: !!r?.is_error };
|
|
600
|
+
break;
|
|
575
601
|
}
|
|
576
602
|
default:
|
|
577
603
|
return { content: `unknown protocol "${protocol}" on tool ${call.name}`, is_error: true };
|
|
578
604
|
}
|
|
605
|
+
|
|
606
|
+
// Async (long-task watch): if the tool declared an `async` block and
|
|
607
|
+
// it ran without error, register a background watch that polls to
|
|
608
|
+
// completion and reports back to the originating chat session. The
|
|
609
|
+
// tool's own result is returned to the caller immediately (detach).
|
|
610
|
+
if (located.tool.async && !result.is_error) {
|
|
611
|
+
try {
|
|
612
|
+
const { registerWatch, DEFAULT_CHAIN_DEPTH } = await import('../watch/register');
|
|
613
|
+
let parsed: unknown = result.content;
|
|
614
|
+
try { parsed = JSON.parse(result.content); } catch { /* keep string */ }
|
|
615
|
+
const reg = registerWatch({
|
|
616
|
+
spec: located.tool.async,
|
|
617
|
+
connectorId: located.connectorId,
|
|
618
|
+
toolName: located.toolName,
|
|
619
|
+
args: argInput,
|
|
620
|
+
result: parsed,
|
|
621
|
+
settings: effectiveSettings,
|
|
622
|
+
sessionId: opts.sessionId ?? null,
|
|
623
|
+
chainDepth: opts.chainDepth ?? DEFAULT_CHAIN_DEPTH,
|
|
624
|
+
});
|
|
625
|
+
const note = reg.ok
|
|
626
|
+
? `\n\n[watch ${reg.watch_id} registered — polling in the background; you'll get a chat update on completion.]`
|
|
627
|
+
: `\n\n[watch not registered: ${reg.reason}]`;
|
|
628
|
+
result = { ...result, content: result.content + note };
|
|
629
|
+
} catch (e) {
|
|
630
|
+
console.warn('[dispatch] registerWatch failed', (e as Error).message);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return result;
|
|
579
634
|
} catch (e) {
|
|
580
635
|
return { content: `connector tool failed: ${(e as Error).message}`, is_error: true };
|
|
581
636
|
}
|
package/lib/chat-standalone.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from './chat/session-store';
|
|
38
38
|
import { runTurn, type AgentEvent } from './chat/agent-loop';
|
|
39
39
|
import { bridgePush } from './chat/bridge-client';
|
|
40
|
+
import { startWatchRunner } from './watch/watch-runner';
|
|
40
41
|
|
|
41
42
|
const PORT = Number(process.env.CHAT_PORT) || 8408;
|
|
42
43
|
const startTime = Date.now();
|
|
@@ -302,6 +303,17 @@ httpServer.listen(PORT, '127.0.0.1', () => {
|
|
|
302
303
|
const main = ensureMainSession();
|
|
303
304
|
console.log(`[chat] Main session: ${main.id.slice(0, 8)} "${main.title}"`);
|
|
304
305
|
} catch (e) { console.warn('[chat] ensureMainSession failed:', (e as Error).message); }
|
|
306
|
+
|
|
307
|
+
// Background long-task watches: poll to completion, then feed the
|
|
308
|
+
// result back into the originating session (assistant replies) or push
|
|
309
|
+
// ambient progress (status chip, not a message).
|
|
310
|
+
startWatchRunner({
|
|
311
|
+
onProgress: (sessionId, payload) => fanoutEvent(sessionId, { type: 'watch_status', data: payload }),
|
|
312
|
+
runChat: (sessionId, text) => {
|
|
313
|
+
void runTurn({ sessionId, userText: text, callbacks: { onEvent: (e) => fanoutEvent(sessionId, e) } })
|
|
314
|
+
.catch((err) => console.error('[watch] runChat failed', (err as Error).message));
|
|
315
|
+
},
|
|
316
|
+
});
|
|
305
317
|
});
|
|
306
318
|
|
|
307
319
|
function shutdown(): void {
|
package/lib/connectors/types.ts
CHANGED
|
@@ -14,7 +14,101 @@
|
|
|
14
14
|
export type ConnectorRunner = 'main' | 'isolated';
|
|
15
15
|
|
|
16
16
|
/** Where a tool's execution lives. */
|
|
17
|
-
export type ConnectorProtocol = 'browser' | 'http' | 'shell';
|
|
17
|
+
export type ConnectorProtocol = 'browser' | 'http' | 'shell' | 'ssh';
|
|
18
|
+
|
|
19
|
+
/** One expect rule for `protocol: ssh`: when output matches `match`
|
|
20
|
+
* (a regex, tested per output chunk), send `send` + Enter. Used to
|
|
21
|
+
* auto-answer interactive prompts like `(y/N)`.
|
|
22
|
+
*
|
|
23
|
+
* `send` may be the INTENT `yes`/`no` — the runner then picks the token
|
|
24
|
+
* the prompt actually offers (`(y/N)` → `y`/`n`, `(yes/no)` → `yes`/`no`)
|
|
25
|
+
* and always sends it explicitly (never relies on the prompt's default).
|
|
26
|
+
* Any other value is sent literally. */
|
|
27
|
+
export interface SshExpectRule {
|
|
28
|
+
match: string;
|
|
29
|
+
send: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One completion/failure action for an async watch. */
|
|
33
|
+
export interface WatchAction {
|
|
34
|
+
/** chat = feed result back to the originating session (assistant replies);
|
|
35
|
+
* tool = dispatch a tool (chaining); none = just record terminal state. */
|
|
36
|
+
mode?: 'chat' | 'tool' | 'none';
|
|
37
|
+
/** mode=chat: text injected into the session (templated with {poll.*}). */
|
|
38
|
+
message?: string;
|
|
39
|
+
/** mode=tool: `<connector>.<tool>` to dispatch. */
|
|
40
|
+
tool?: string;
|
|
41
|
+
/** mode=tool: args (templated with {poll.*}/{args.*}). */
|
|
42
|
+
args?: Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `async` block — declares a tool as a long-running task that Forge
|
|
47
|
+
* watches in the background. Templating in poll_args / done_match.equals /
|
|
48
|
+
* action args / progress.message: {args.*} = the trigger's input,
|
|
49
|
+
* {result.*} = the trigger's return value (resolved once at register
|
|
50
|
+
* time), {poll.*} = the latest poll result (resolved per use).
|
|
51
|
+
*/
|
|
52
|
+
export interface AsyncWatchSpec {
|
|
53
|
+
/** Tool to poll (same connector), bare tool name e.g. `get_version`. */
|
|
54
|
+
poll: string;
|
|
55
|
+
/** Args for each poll call (templated against trigger args/result). */
|
|
56
|
+
poll_args?: Record<string, unknown>;
|
|
57
|
+
/** (A) poll-result path that, when truthy, means done. */
|
|
58
|
+
done_path?: string;
|
|
59
|
+
/** (B) value comparison on a poll-result path. */
|
|
60
|
+
done_match?: { path: string; equals?: string; contains?: string };
|
|
61
|
+
/** poll-result path that, when truthy, means failed. */
|
|
62
|
+
fail_path?: string;
|
|
63
|
+
/** Seconds between polls (default 60, min 30). */
|
|
64
|
+
interval_sec?: number;
|
|
65
|
+
/** Overall deadline in seconds (default 1200). */
|
|
66
|
+
timeout_sec?: number;
|
|
67
|
+
/** Hard cap on poll count (default 40). */
|
|
68
|
+
max_polls?: number;
|
|
69
|
+
/** Completion action (default {mode:'chat'}). */
|
|
70
|
+
on_done?: WatchAction;
|
|
71
|
+
/** Failure/timeout action (default {mode:'chat'}). */
|
|
72
|
+
on_fail?: WatchAction;
|
|
73
|
+
/** Per-poll ambient progress (does not enter the message thread). */
|
|
74
|
+
progress?: { show?: boolean; message?: string };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `protocol: ssh` spec — drives an interactive SSH session via a PTY
|
|
79
|
+
* (the system `ssh` binary). Built for devices whose CLI needs a
|
|
80
|
+
* password + interactive confirmations (e.g. FortiNAC firmware restore).
|
|
81
|
+
* All string fields are templated with {settings.*}/{args.*}.
|
|
82
|
+
*/
|
|
83
|
+
export interface SshSpec {
|
|
84
|
+
// Connection params are resolved by the runner with this precedence:
|
|
85
|
+
// tool arg (host/port/username/password) > connector setting
|
|
86
|
+
// (host/port/username/password) > the literal here > built-in default.
|
|
87
|
+
// So chat can pass them per-call and the connector holds defaults; the
|
|
88
|
+
// IP typically comes from chat only (no setting). All optional here.
|
|
89
|
+
host?: string;
|
|
90
|
+
/** Default 22. */
|
|
91
|
+
port?: string | number;
|
|
92
|
+
user?: string;
|
|
93
|
+
/** Password fed when a `password:` prompt appears (sent silently). */
|
|
94
|
+
password?: string;
|
|
95
|
+
/** Commands sent one-per-shell-prompt, in order (e.g. the upgrade cmd, then exit). */
|
|
96
|
+
commands?: string[];
|
|
97
|
+
/** Auto-answers applied throughout the session (e.g. `(y/N)` → `y`). */
|
|
98
|
+
auto_answer?: SshExpectRule[];
|
|
99
|
+
/** Shell-prompt regex that gates sending the next command. Default `[#$>]\s*$`. */
|
|
100
|
+
prompt_regex?: string;
|
|
101
|
+
/** Success marker regex — when seen, the session resolves ok and ssh is closed. */
|
|
102
|
+
done_when?: string;
|
|
103
|
+
/** Failure marker regex — when seen, resolves is_error. */
|
|
104
|
+
fail_when?: string;
|
|
105
|
+
/** name → regex(1 capture group) pulled from the full transcript into the result. */
|
|
106
|
+
capture?: Record<string, string>;
|
|
107
|
+
/** Overall timeout. Default 120s, max 280s. */
|
|
108
|
+
timeout_sec?: number;
|
|
109
|
+
/** Treat the remote closing the connection as success (e.g. after `exit`). */
|
|
110
|
+
success_on_close?: boolean;
|
|
111
|
+
}
|
|
18
112
|
|
|
19
113
|
/** Schema for one settings or parameter field. */
|
|
20
114
|
export interface ConnectorFieldSchema {
|
|
@@ -170,7 +264,29 @@ export interface ConnectorTool {
|
|
|
170
264
|
/** Extra env vars (values templated). */
|
|
171
265
|
env?: Record<string, string>;
|
|
172
266
|
|
|
173
|
-
|
|
267
|
+
// ── protocol: 'ssh' ───────────────────────────────────────
|
|
268
|
+
/** Interactive SSH session spec (PTY-driven). See SshSpec. */
|
|
269
|
+
ssh?: SshSpec;
|
|
270
|
+
|
|
271
|
+
// ── async (long-task watch) ───────────────────────────────
|
|
272
|
+
/**
|
|
273
|
+
* Marks this tool as a long-running task. After it runs (and detaches
|
|
274
|
+
* quickly), Forge registers a background **watch** that periodically
|
|
275
|
+
* polls `async.poll` until done/failed/timeout, then feeds the result
|
|
276
|
+
* back into the originating chat session (or chains a tool). See
|
|
277
|
+
* AsyncWatchSpec + lib/watch/. The lightweight async-callback primitive
|
|
278
|
+
* for chat-driven background tasks (NAC upgrade, pytest runs, …).
|
|
279
|
+
*/
|
|
280
|
+
async?: AsyncWatchSpec;
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Timeout in milliseconds.
|
|
284
|
+
* - shell/http: request timeout (default 30000, max 300000).
|
|
285
|
+
* - browser: how long the bridge waits for the extension to return the
|
|
286
|
+
* RPC result (default 60000, capped at 900000). Raise it for tools
|
|
287
|
+
* whose script issues a long synchronous backend call (e.g. a NAC
|
|
288
|
+
* upgrade that blocks for minutes).
|
|
289
|
+
*/
|
|
174
290
|
timeout_ms?: number;
|
|
175
291
|
|
|
176
292
|
/**
|
|
@@ -451,3 +451,45 @@ When the user reports a bug ("the list_my_issues tool returned 0 rows"):
|
|
|
451
451
|
`script` body needs to change. Bump version, save, retry — the
|
|
452
452
|
registry-based update path is for connectors that came from a
|
|
453
453
|
shared `forge-connectors` repo.
|
|
454
|
+
|
|
455
|
+
## Long-running tools — the `async` block (background watch)
|
|
456
|
+
|
|
457
|
+
A tool that kicks off work which finishes minutes later (a firmware
|
|
458
|
+
upgrade, a test run) should NOT hold the chat open. Declare an `async`
|
|
459
|
+
block: the tool runs and returns immediately (detach), and Forge
|
|
460
|
+
registers a background **watch** that polls until done, then reports
|
|
461
|
+
back into the originating chat session — no AI babysitting.
|
|
462
|
+
|
|
463
|
+
```yaml
|
|
464
|
+
upgrade:
|
|
465
|
+
protocol: ssh
|
|
466
|
+
async:
|
|
467
|
+
poll: get_version # another tool in THIS connector to poll
|
|
468
|
+
poll_args: # built once from the trigger's args/result
|
|
469
|
+
host: "{args.host}" # {args.*}=trigger input, {result.*}=trigger return
|
|
470
|
+
# completion test — one of:
|
|
471
|
+
done_path: done # (a) poll-result path is truthy
|
|
472
|
+
done_match: # (b) value compare
|
|
473
|
+
path: captured.build
|
|
474
|
+
equals: "{result.captured.target_build}"
|
|
475
|
+
fail_path: error # optional: truthy = failed
|
|
476
|
+
interval_sec: 60 # poll cadence (min 30)
|
|
477
|
+
timeout_sec: 900 # overall deadline
|
|
478
|
+
max_polls: 15 # hard cap
|
|
479
|
+
on_done: { mode: chat, message: "Done — build {poll.captured.build}." }
|
|
480
|
+
on_fail: { mode: chat, message: "Not confirmed — check manually." }
|
|
481
|
+
progress: { show: true, message: "Working… {poll_count}/{max_polls}" }
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
- `on_done`/`on_fail.mode`: `chat` (default — assistant replies in the
|
|
485
|
+
session; a telegram-origin session replies on telegram), `tool`
|
|
486
|
+
(chain another tool — `tool` + `args`, depth-limited), or `none`.
|
|
487
|
+
- `progress` shows an ambient status chip per poll — it does NOT enter
|
|
488
|
+
the message thread or trigger the LLM.
|
|
489
|
+
- Templating: `{poll.*}` = latest poll result; `{poll_count}`/`{max_polls}`.
|
|
490
|
+
- Guards (not overridable to unbounded): max_polls, timeout,
|
|
491
|
+
max_lifetime, consecutive-error cutoff, chain depth, global active cap.
|
|
492
|
+
- Watches persist in SQLite (survive restart) and are listed/cancelable
|
|
493
|
+
in /chat's "Background watches" panel.
|
|
494
|
+
- Secrets: don't put a password in `poll_args` (it persists in the watch
|
|
495
|
+
row). Rely on the connector's saved default credential instead.
|