@aion0/forge 0.10.22 → 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/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 +15 -2
- package/lib/chat/agent-loop.ts +32 -2
- package/lib/chat/tool-dispatcher.ts +45 -5
- package/lib/chat-standalone.ts +12 -0
- package/lib/connectors/types.ts +56 -0
- 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
|
@@ -483,6 +483,12 @@ export interface DispatchOptions {
|
|
|
483
483
|
* therefore don't need an LLM-friendly truncation.
|
|
484
484
|
*/
|
|
485
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;
|
|
486
492
|
}
|
|
487
493
|
|
|
488
494
|
export async function dispatchTool(
|
|
@@ -567,30 +573,64 @@ export async function dispatchTool(
|
|
|
567
573
|
}
|
|
568
574
|
|
|
569
575
|
try {
|
|
576
|
+
let result: ToolResult;
|
|
570
577
|
switch (protocol) {
|
|
571
578
|
case 'http':
|
|
572
|
-
|
|
579
|
+
result = await runHttp({ tool: located.tool, settings: effectiveSettings, args: argInput, connectorAuth: def.auth, noTruncation: opts.noTruncation });
|
|
580
|
+
break;
|
|
573
581
|
case 'shell':
|
|
574
|
-
|
|
582
|
+
result = await runShell({ tool: located.tool, settings: effectiveSettings, args: argInput });
|
|
583
|
+
break;
|
|
575
584
|
case 'ssh':
|
|
576
|
-
|
|
585
|
+
result = await runSsh({ tool: located.tool, settings: effectiveSettings, args: argInput });
|
|
586
|
+
break;
|
|
577
587
|
case 'browser': {
|
|
578
588
|
// Hand the whole connector + tool spec + input + settings to the
|
|
579
589
|
// extension's runner.ts via the bridge. The extension keeps owning
|
|
580
590
|
// the runner logic (tab acquire, navigate, executeScript).
|
|
581
591
|
const connector = buildConnectorPayload(def, located.entry, effectiveSettings);
|
|
582
|
-
const
|
|
592
|
+
const r = (await bridgeRpc('connector.run', {
|
|
583
593
|
pluginId: located.connectorId, // wire-name kept for extension
|
|
584
594
|
toolName: located.toolName,
|
|
585
595
|
input: argInput,
|
|
586
596
|
connector,
|
|
587
597
|
settings: effectiveSettings,
|
|
588
598
|
}, located.tool.timeout_ms)) as { content?: string; is_error?: boolean } | null;
|
|
589
|
-
|
|
599
|
+
result = { content: r?.content ?? '(no content returned)', is_error: !!r?.is_error };
|
|
600
|
+
break;
|
|
590
601
|
}
|
|
591
602
|
default:
|
|
592
603
|
return { content: `unknown protocol "${protocol}" on tool ${call.name}`, is_error: true };
|
|
593
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;
|
|
594
634
|
} catch (e) {
|
|
595
635
|
return { content: `connector tool failed: ${(e as Error).message}`, is_error: true };
|
|
596
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
|
@@ -29,6 +29,51 @@ export interface SshExpectRule {
|
|
|
29
29
|
send: string;
|
|
30
30
|
}
|
|
31
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
|
+
|
|
32
77
|
/**
|
|
33
78
|
* `protocol: ssh` spec — drives an interactive SSH session via a PTY
|
|
34
79
|
* (the system `ssh` binary). Built for devices whose CLI needs a
|
|
@@ -223,6 +268,17 @@ export interface ConnectorTool {
|
|
|
223
268
|
/** Interactive SSH session spec (PTY-driven). See SshSpec. */
|
|
224
269
|
ssh?: SshSpec;
|
|
225
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
|
+
|
|
226
282
|
/**
|
|
227
283
|
* Timeout in milliseconds.
|
|
228
284
|
* - shell/http: request timeout (default 30000, max 300000).
|
|
@@ -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.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Background Watches
|
|
2
|
+
|
|
3
|
+
A **watch** is Forge's lightweight async primitive for long-running jobs:
|
|
4
|
+
you (or the assistant) kick something off that finishes minutes later —
|
|
5
|
+
a device firmware upgrade, a Jenkins build, a test run — and instead of
|
|
6
|
+
the assistant sitting in the conversation polling (burning tokens,
|
|
7
|
+
getting stuck), Forge polls it in the **background** and posts the result
|
|
8
|
+
back into this chat when it's done.
|
|
9
|
+
|
|
10
|
+
You don't manage watches directly most of the time — they appear when a
|
|
11
|
+
long job is started and clear themselves when it finishes.
|
|
12
|
+
|
|
13
|
+
## What you see
|
|
14
|
+
|
|
15
|
+
- **Progress chip** — while a watch runs, a small status pill shows
|
|
16
|
+
above the chat composer (e.g. "Upgrading NAC 10.15.52.152… poll 3/15,
|
|
17
|
+
build 6956"). It updates in place, is **not** a chat message (doesn't
|
|
18
|
+
clutter the thread), and disappears when the job finishes.
|
|
19
|
+
- **Completion message** — when the job reaches its goal (or fails /
|
|
20
|
+
times out), a normal assistant message lands in the chat:
|
|
21
|
+
"NAC … upgrade confirmed — now running build 6957." If your session
|
|
22
|
+
came from Telegram, that message arrives in Telegram.
|
|
23
|
+
- **Watch list** — see and control active watches in two places:
|
|
24
|
+
- **/chat** web: the "Background watches" panel in the left sidebar.
|
|
25
|
+
- **Forge web**: user menu → **Monitor** → "Background Watches".
|
|
26
|
+
Each entry shows status (active / done / failed / timed_out), poll
|
|
27
|
+
count, and **Cancel** / **Delete** buttons.
|
|
28
|
+
|
|
29
|
+
Watches survive a Forge restart (persisted in SQLite) — an in-flight
|
|
30
|
+
upgrade keeps being watched after a restart, no chat needed.
|
|
31
|
+
|
|
32
|
+
## Two ways a watch starts
|
|
33
|
+
|
|
34
|
+
**1. Connector built-in (`async` block).** Some connector tools are
|
|
35
|
+
pre-declared as long tasks by their author — e.g. `nac.upgrade`
|
|
36
|
+
automatically registers a watch that polls `nac.get_version` until the
|
|
37
|
+
new build is running. Nothing for you to do; it just works.
|
|
38
|
+
|
|
39
|
+
**2. The assistant decides (`start_watch`).** For long jobs that aren't
|
|
40
|
+
pre-declared — a Jenkins build, a one-off cross-connector flow — the
|
|
41
|
+
assistant calls the `start_watch` builtin on the fly: it triggers the
|
|
42
|
+
job, registers a watch to poll the right status tool, and then stops
|
|
43
|
+
talking. You get the result later in chat.
|
|
44
|
+
|
|
45
|
+
Both use the same background machinery; you experience them identically.
|
|
46
|
+
|
|
47
|
+
## `start_watch` (for the assistant)
|
|
48
|
+
|
|
49
|
+
When you've just started a long job and have a tool that reports its
|
|
50
|
+
status, register a watch instead of polling in the conversation:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
start_watch({
|
|
54
|
+
poll: "jenkins.get_build", // <connector>.<status tool>
|
|
55
|
+
poll_args: { job_path: "job/foo", build_number: 18 },
|
|
56
|
+
done_match: { path: "result", equals: "SUCCESS" }, // or done_path (truthy)
|
|
57
|
+
interval_sec: 60, timeout_sec: 1800,
|
|
58
|
+
message: "Build 18 finished: {poll.result}" // {poll.<path>} = latest result
|
|
59
|
+
})
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Then **stop** — do not keep calling get_build in the conversation. A
|
|
63
|
+
completion message arrives in chat; the user can cancel it from the watch
|
|
64
|
+
list. Pick `done_match`/`done_path` from a field you saw in the status
|
|
65
|
+
tool's output (you usually called it once already). Queue/startup errors
|
|
66
|
+
(e.g. a build number 404 while still queued) are tolerated for a while.
|
|
67
|
+
Guards (max polls, timeout, lifetime, active cap) keep it from running
|
|
68
|
+
away; worst case it times out and reports that.
|
|
69
|
+
|
|
70
|
+
## Limits
|
|
71
|
+
|
|
72
|
+
- Watches are short-lived; they end at done / failed / timed_out /
|
|
73
|
+
cancelled and stop polling.
|
|
74
|
+
- `start_watch` reports back only via chat — it doesn't open a separate
|
|
75
|
+
notification channel. Telegram-origin sessions reply on Telegram.
|
|
76
|
+
- A watch can't *fix* a job that's stuck waiting for human action (a
|
|
77
|
+
build needing approval); it only watches to a final state and reports.
|
package/lib/help-docs/CLAUDE.md
CHANGED
|
@@ -50,6 +50,7 @@ The token is valid for 24 hours. Store it in a variable and reuse for all API ca
|
|
|
50
50
|
| `21-build-connector.md` | **Authoring** a custom connector — interview script, manifest template (browser / http / shell protocols), how to install locally via the Forge data dir or a zip upload. Use this when the user asks to BUILD a connector, not when they ask about an existing one. |
|
|
51
51
|
| `18-chrome-mcp.md` | Connect Forge Claude Code sessions to a real Chrome via chrome-devtools-mcp — dev-time browser access for connector authoring |
|
|
52
52
|
| `23-automation-states.md` | Fortinet pipeline automation: GitLab MR stage labels, Mantis status flow, Teams notify policy |
|
|
53
|
+
| `24-watch.md` | Background watches — async polling of long jobs (device upgrade, Jenkins build, test run) that report back in chat. Two triggers: connectors' declarative `async` block, and the `start_watch` builtin the assistant calls on the fly. Where to see/cancel them. |
|
|
53
54
|
|
|
54
55
|
## Matching questions to docs
|
|
55
56
|
|
|
@@ -83,3 +84,4 @@ The token is valid for 24 hours. Store it in a variable and reuse for all API ca
|
|
|
83
84
|
- Job / scheduled job / connector poll / "Jobs tab" → tell user: **Jobs is deprecated**; use Schedules (`13-schedules.md`) instead.
|
|
84
85
|
- Recipe / "From recipe" form / parameterized job → tell user: **recipes deprecated** along with Jobs; fire pipelines manually or via Schedules.
|
|
85
86
|
- Mantis bug fix / fortinet-mantis-bug-fix / open MR for Mantis bug / fortinet-mr-review / pre-review / GitLab stage labels → `23-automation-states.md` (kept Fortinet pipelines)
|
|
87
|
+
- Background watch / "watch this build" / "tell me when the upgrade is done" / progress chip / start_watch / async long job / why is the assistant polling → `24-watch.md`
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* registerWatch — turn a just-run `async` tool into a background watch.
|
|
3
|
+
*
|
|
4
|
+
* Called by the tool-dispatcher right after a tool with an `async` block
|
|
5
|
+
* runs (and detaches). Resolves the spec's templates against the
|
|
6
|
+
* trigger's args/result, enforces the global active cap and chain-depth
|
|
7
|
+
* guard, and persists a watch row. The watch-runner (chat-standalone)
|
|
8
|
+
* picks it up on the next tick. No dependency on the runner or
|
|
9
|
+
* tool-dispatcher → no import cycle.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { AsyncWatchSpec } from '../connectors/types';
|
|
13
|
+
import { resolveDeep, resolveTemplate } from './template';
|
|
14
|
+
import { createWatch, countActive, type WatchAction } from './watch-store';
|
|
15
|
+
|
|
16
|
+
export const MAX_ACTIVE_WATCHES = 50;
|
|
17
|
+
export const DEFAULT_CHAIN_DEPTH = 3;
|
|
18
|
+
const MIN_INTERVAL_SEC = 30;
|
|
19
|
+
const DEFAULT_INTERVAL_SEC = 60;
|
|
20
|
+
const DEFAULT_TIMEOUT_SEC = 1200;
|
|
21
|
+
const DEFAULT_MAX_POLLS = 40;
|
|
22
|
+
|
|
23
|
+
export interface RegisterWatchCtx {
|
|
24
|
+
spec: AsyncWatchSpec;
|
|
25
|
+
connectorId: string;
|
|
26
|
+
toolName: string;
|
|
27
|
+
args: Record<string, unknown>; // trigger tool input
|
|
28
|
+
result: unknown; // trigger tool result (parsed)
|
|
29
|
+
settings: Record<string, unknown>;
|
|
30
|
+
sessionId: string | null;
|
|
31
|
+
chainDepth: number; // remaining chain budget
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type RegisterResult =
|
|
35
|
+
| { ok: true; watch_id: string; label: string }
|
|
36
|
+
| { ok: false; reason: string };
|
|
37
|
+
|
|
38
|
+
function num(v: unknown, dflt: number): number {
|
|
39
|
+
const n = Number(v);
|
|
40
|
+
return Number.isFinite(n) && n > 0 ? n : dflt;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function registerWatch(ctx: RegisterWatchCtx): RegisterResult {
|
|
44
|
+
const { spec } = ctx;
|
|
45
|
+
if (!spec || !spec.poll) return { ok: false, reason: 'no async.poll declared' };
|
|
46
|
+
if (ctx.chainDepth <= 0) return { ok: false, reason: 'chain depth exhausted' };
|
|
47
|
+
if (countActive() >= MAX_ACTIVE_WATCHES) {
|
|
48
|
+
return { ok: false, reason: `active watch limit reached (${MAX_ACTIVE_WATCHES})` };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Templating context for register-time resolution.
|
|
52
|
+
const regCtx = { args: ctx.args, result: ctx.result, settings: ctx.settings };
|
|
53
|
+
|
|
54
|
+
const pollArgs = (resolveDeep(spec.poll_args || {}, regCtx) as Record<string, unknown>) || {};
|
|
55
|
+
|
|
56
|
+
let doneMatch: { path: string; equals?: string; contains?: string } | null = null;
|
|
57
|
+
if (spec.done_match && spec.done_match.path) {
|
|
58
|
+
doneMatch = {
|
|
59
|
+
path: spec.done_match.path,
|
|
60
|
+
...(spec.done_match.equals != null ? { equals: resolveTemplate(String(spec.done_match.equals), regCtx) } : {}),
|
|
61
|
+
...(spec.done_match.contains != null ? { contains: resolveTemplate(String(spec.done_match.contains), regCtx) } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const interval = Math.max(MIN_INTERVAL_SEC, num(spec.interval_sec, DEFAULT_INTERVAL_SEC));
|
|
66
|
+
const timeout = num(spec.timeout_sec, DEFAULT_TIMEOUT_SEC);
|
|
67
|
+
const maxPolls = num(spec.max_polls, DEFAULT_MAX_POLLS);
|
|
68
|
+
|
|
69
|
+
// Label: connector.tool + a hint (host/lab/ip) pulled from args if present.
|
|
70
|
+
const hint = ['host', 'lab', 'ip', 'name'].map((k) => ctx.args[k]).find((v) => typeof v === 'string' && v);
|
|
71
|
+
const label = `${ctx.connectorId}.${ctx.toolName}${hint ? ` ${hint}` : ''}`;
|
|
72
|
+
|
|
73
|
+
// Pre-resolve {args.*}/{result.*}/{settings.*} in messages/action-args NOW
|
|
74
|
+
// (they're fixed at register time). {poll.*}/{poll_count}/{max_polls} are
|
|
75
|
+
// left literal for the runner to fill per poll — resolveTemplate keeps
|
|
76
|
+
// tokens whose namespace isn't in the context, so the two passes compose.
|
|
77
|
+
const preAction = (a: WatchAction | undefined): WatchAction | null => {
|
|
78
|
+
if (!a) return null;
|
|
79
|
+
return {
|
|
80
|
+
...a,
|
|
81
|
+
...(a.message ? { message: resolveTemplate(a.message, regCtx) } : {}),
|
|
82
|
+
...(a.args ? { args: resolveDeep(a.args, regCtx) as Record<string, unknown> } : {}),
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
const preProgress = spec.progress
|
|
86
|
+
? { ...spec.progress, ...(spec.progress.message ? { message: resolveTemplate(spec.progress.message, regCtx) } : {}) }
|
|
87
|
+
: null;
|
|
88
|
+
|
|
89
|
+
const w = createWatch({
|
|
90
|
+
session_id: ctx.sessionId,
|
|
91
|
+
label,
|
|
92
|
+
connector_id: ctx.connectorId,
|
|
93
|
+
poll_tool: spec.poll,
|
|
94
|
+
poll_args: pollArgs,
|
|
95
|
+
done_path: spec.done_path ?? null,
|
|
96
|
+
done_match: doneMatch,
|
|
97
|
+
fail_path: spec.fail_path ?? null,
|
|
98
|
+
on_done: preAction(spec.on_done as WatchAction),
|
|
99
|
+
on_fail: preAction(spec.on_fail as WatchAction),
|
|
100
|
+
progress: preProgress,
|
|
101
|
+
interval_sec: interval,
|
|
102
|
+
timeout_sec: timeout,
|
|
103
|
+
max_polls: maxPolls,
|
|
104
|
+
chain_depth: ctx.chainDepth,
|
|
105
|
+
now: Date.now(),
|
|
106
|
+
});
|
|
107
|
+
return { ok: true, watch_id: w.id, label };
|
|
108
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `start_watch` — a builtin tool that lets the LLM register a background
|
|
3
|
+
* watch on the fly, instead of relying on a connector author's static
|
|
4
|
+
* `async` block. Use case: the model just triggered a long job (jenkins
|
|
5
|
+
* build, a test run) and, rather than polling in-conversation (burning
|
|
6
|
+
* tokens, getting stuck), it calls start_watch to have Forge poll a
|
|
7
|
+
* given tool until a done/fail condition, then report back in chat.
|
|
8
|
+
*
|
|
9
|
+
* This is the dynamic counterpart to manifest `async` blocks — same
|
|
10
|
+
* watch backend (store + runner + chip), just driven by the model. The
|
|
11
|
+
* handler closes over the originating session id so completion routes to
|
|
12
|
+
* the right conversation.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { BuiltinToolDef } from '../chat/tool-dispatcher';
|
|
16
|
+
import { createWatch, countActive } from './watch-store';
|
|
17
|
+
import { MAX_ACTIVE_WATCHES, DEFAULT_CHAIN_DEPTH } from './register';
|
|
18
|
+
|
|
19
|
+
const MIN_INTERVAL_SEC = 30;
|
|
20
|
+
const DEFAULT_INTERVAL_SEC = 60;
|
|
21
|
+
const DEFAULT_TIMEOUT_SEC = 1800;
|
|
22
|
+
const DEFAULT_MAX_POLLS = 40;
|
|
23
|
+
|
|
24
|
+
export interface StartWatchTool {
|
|
25
|
+
def: BuiltinToolDef;
|
|
26
|
+
handle: (input: unknown) => Promise<string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function num(v: unknown, dflt: number): number {
|
|
30
|
+
const n = Number(v);
|
|
31
|
+
return Number.isFinite(n) && n > 0 ? n : dflt;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildStartWatchTool(sessionId: string | null): StartWatchTool {
|
|
35
|
+
const def: BuiltinToolDef = {
|
|
36
|
+
name: 'start_watch',
|
|
37
|
+
description:
|
|
38
|
+
'Register a BACKGROUND WATCH that polls a tool until done, then posts the result back here — for long-running jobs you just kicked off (a Jenkins build, a test run, a device upgrade). Use this INSTEAD of polling in conversation: call the trigger tool, then call start_watch and STOP — Forge polls in the background and a completion message arrives in this chat. ' +
|
|
39
|
+
'Pick `poll` = the read tool that reports status (e.g. "jenkins.get_build") and `poll_args` to call it with (e.g. the build number you predicted via get_next_build_number). Give a done condition: `done_match` {path, equals} on the poll result (e.g. path "result" equals "SUCCESS"), or `done_path` (a result path that becomes truthy). You usually already saw the poll tool\'s output once, so you know the right field. Optional `fail_path` (truthy = failed). Tune `interval_sec`/`timeout_sec` to the job (build ≈ 60s / 1800s).',
|
|
40
|
+
input_schema: {
|
|
41
|
+
type: 'object',
|
|
42
|
+
properties: {
|
|
43
|
+
poll: { type: 'string', description: 'Tool to poll, "<connector>.<tool>" e.g. "jenkins.get_build". Must be a read/status tool.' },
|
|
44
|
+
poll_args: { type: 'object', description: 'Args to call the poll tool with each tick, e.g. {"job_path":"job/foo","build_number":18}. Concrete values, not templates.' },
|
|
45
|
+
done_match: {
|
|
46
|
+
type: 'object',
|
|
47
|
+
description: 'Done when poll-result <path> equals/contains this. e.g. {"path":"result","equals":"SUCCESS"}.',
|
|
48
|
+
properties: {
|
|
49
|
+
path: { type: 'string' },
|
|
50
|
+
equals: { type: 'string' },
|
|
51
|
+
contains: { type: 'string' },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
done_path: { type: 'string', description: 'Alternative to done_match: done when this poll-result path is truthy.' },
|
|
55
|
+
fail_path: { type: 'string', description: 'Optional: poll-result path that, when truthy, means failed.' },
|
|
56
|
+
message: { type: 'string', description: 'Message posted to chat on completion. May reference {poll.<path>}, e.g. "Build 18 finished: {poll.result}".' },
|
|
57
|
+
fail_message: { type: 'string', description: 'Optional message on failure/timeout.' },
|
|
58
|
+
progress_message: { type: 'string', description: 'Optional per-poll status chip text (ambient, not a chat message). e.g. "Build 18 — {poll.result}".' },
|
|
59
|
+
interval_sec: { type: 'number', description: 'Seconds between polls (default 60, min 30).' },
|
|
60
|
+
timeout_sec: { type: 'number', description: 'Overall deadline in seconds (default 1800).' },
|
|
61
|
+
max_polls: { type: 'number', description: 'Hard cap on poll count (default 40).' },
|
|
62
|
+
},
|
|
63
|
+
required: ['poll'],
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const handle = async (input: unknown): Promise<string> => {
|
|
68
|
+
const a = (input ?? {}) as Record<string, any>;
|
|
69
|
+
const poll = String(a.poll || '').trim();
|
|
70
|
+
const dot = poll.indexOf('.');
|
|
71
|
+
if (dot < 1) return JSON.stringify({ ok: false, error: 'poll must be "<connector>.<tool>", e.g. jenkins.get_build' });
|
|
72
|
+
const connectorId = poll.slice(0, dot);
|
|
73
|
+
const pollTool = poll.slice(dot + 1);
|
|
74
|
+
|
|
75
|
+
const doneMatch = a.done_match && typeof a.done_match === 'object' && a.done_match.path
|
|
76
|
+
? { path: String(a.done_match.path), ...(a.done_match.equals != null ? { equals: String(a.done_match.equals) } : {}), ...(a.done_match.contains != null ? { contains: String(a.done_match.contains) } : {}) }
|
|
77
|
+
: null;
|
|
78
|
+
const donePath = typeof a.done_path === 'string' && a.done_path ? a.done_path : null;
|
|
79
|
+
if (!doneMatch && !donePath) {
|
|
80
|
+
return JSON.stringify({ ok: false, error: 'give a done condition: done_match {path,equals} or done_path' });
|
|
81
|
+
}
|
|
82
|
+
if (countActive() >= MAX_ACTIVE_WATCHES) {
|
|
83
|
+
return JSON.stringify({ ok: false, error: `active watch limit reached (${MAX_ACTIVE_WATCHES})` });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const hint = ['build_number', 'host', 'ip', 'lab', 'id', 'name'].map((k) => a.poll_args?.[k]).find((v) => v != null && v !== '');
|
|
87
|
+
const label = `${connectorId}.${pollTool}${hint != null ? ` ${hint}` : ''}`;
|
|
88
|
+
|
|
89
|
+
const w = createWatch({
|
|
90
|
+
session_id: sessionId,
|
|
91
|
+
label,
|
|
92
|
+
connector_id: connectorId,
|
|
93
|
+
poll_tool: pollTool,
|
|
94
|
+
poll_args: (a.poll_args && typeof a.poll_args === 'object') ? a.poll_args : {},
|
|
95
|
+
done_path: donePath,
|
|
96
|
+
done_match: doneMatch,
|
|
97
|
+
fail_path: typeof a.fail_path === 'string' && a.fail_path ? a.fail_path : null,
|
|
98
|
+
on_done: { mode: 'chat', message: String(a.message || `${label}: done.`) },
|
|
99
|
+
on_fail: { mode: 'chat', message: String(a.fail_message || `${label}: did not complete in time — please check.`) },
|
|
100
|
+
progress: { show: true, ...(a.progress_message ? { message: String(a.progress_message) } : {}) },
|
|
101
|
+
interval_sec: Math.max(MIN_INTERVAL_SEC, num(a.interval_sec, DEFAULT_INTERVAL_SEC)),
|
|
102
|
+
timeout_sec: num(a.timeout_sec, DEFAULT_TIMEOUT_SEC),
|
|
103
|
+
max_polls: num(a.max_polls, DEFAULT_MAX_POLLS),
|
|
104
|
+
chain_depth: DEFAULT_CHAIN_DEPTH,
|
|
105
|
+
now: Date.now(),
|
|
106
|
+
});
|
|
107
|
+
return JSON.stringify({
|
|
108
|
+
ok: true,
|
|
109
|
+
watch_id: w.id,
|
|
110
|
+
polling: poll,
|
|
111
|
+
note: 'Background watch registered. STOP polling in conversation — a completion message will arrive in this chat. The user can see/cancel it in the watch list.',
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return { def, handle };
|
|
116
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny namespaced template resolver for watch specs. Replaces tokens
|
|
3
|
+
* like {args.x}, {result.fired_at}, {poll.build}, {settings.host} from a
|
|
4
|
+
* context of namespaces. Dot paths supported. Unknown tokens are left
|
|
5
|
+
* literal (so a typo is visible rather than silently empty). No eval.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export function getPath(obj: unknown, path: string): unknown {
|
|
9
|
+
let v: any = obj;
|
|
10
|
+
for (const p of path.split('.')) {
|
|
11
|
+
if (v == null || typeof v !== 'object') return undefined;
|
|
12
|
+
v = v[p];
|
|
13
|
+
}
|
|
14
|
+
return v;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolveTemplate(str: string, ctx: Record<string, unknown>): string {
|
|
18
|
+
return str.replace(/\{([^{}]+)\}/g, (full, raw) => {
|
|
19
|
+
const key = String(raw).trim();
|
|
20
|
+
const dot = key.indexOf('.');
|
|
21
|
+
const ns = dot < 0 ? key : key.slice(0, dot);
|
|
22
|
+
const rest = dot < 0 ? '' : key.slice(dot + 1);
|
|
23
|
+
if (!(ns in ctx)) return full;
|
|
24
|
+
const v = rest ? getPath(ctx[ns], rest) : ctx[ns];
|
|
25
|
+
if (v == null) return full;
|
|
26
|
+
return typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Resolve templates throughout a value (strings, arrays, plain objects). */
|
|
31
|
+
export function resolveDeep(val: unknown, ctx: Record<string, unknown>): unknown {
|
|
32
|
+
if (typeof val === 'string') return resolveTemplate(val, ctx);
|
|
33
|
+
if (Array.isArray(val)) return val.map((v) => resolveDeep(v, ctx));
|
|
34
|
+
if (val && typeof val === 'object') {
|
|
35
|
+
const out: Record<string, unknown> = {};
|
|
36
|
+
for (const [k, v] of Object.entries(val)) out[k] = resolveDeep(v, ctx);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
return val;
|
|
40
|
+
}
|