@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.
@@ -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
- return await runHttp({ tool: located.tool, settings: effectiveSettings, args: argInput, connectorAuth: def.auth, noTruncation: opts.noTruncation });
579
+ result = await runHttp({ tool: located.tool, settings: effectiveSettings, args: argInput, connectorAuth: def.auth, noTruncation: opts.noTruncation });
580
+ break;
573
581
  case 'shell':
574
- return await runShell({ tool: located.tool, settings: effectiveSettings, args: argInput });
582
+ result = await runShell({ tool: located.tool, settings: effectiveSettings, args: argInput });
583
+ break;
575
584
  case 'ssh':
576
- return await runSsh({ tool: located.tool, settings: effectiveSettings, args: argInput });
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 result = (await bridgeRpc('connector.run', {
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
- return { content: result?.content ?? '(no content returned)', is_error: !!result?.is_error };
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
  }
@@ -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 {
@@ -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.
@@ -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
+ }