talon-agent 1.34.0 → 1.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,6 +21,7 @@ import { skillTools } from "./skills.js";
21
21
  import { webTools } from "./web.js";
22
22
  import { adminTools } from "./admin.js";
23
23
  import { modelTools } from "./models.js";
24
+ import { meshTools } from "./mesh.js";
24
25
 
25
26
  /** All built-in tool definitions. */
26
27
  export const ALL_TOOLS: readonly ToolDefinition[] = [
@@ -38,6 +39,7 @@ export const ALL_TOOLS: readonly ToolDefinition[] = [
38
39
  ...webTools,
39
40
  ...adminTools,
40
41
  ...modelTools,
42
+ ...meshTools,
41
43
  ];
42
44
 
43
45
  /**
@@ -55,6 +57,17 @@ const TURN_TERMINATOR_NAMES: ReadonlySet<string> = new Set(
55
57
  ALL_TOOLS.filter((t) => t.endsTurn).map((t) => t.name),
56
58
  );
57
59
 
60
+ /**
61
+ * Names of reply-delivery tools (`delivery: true` on the definition):
62
+ * their observable effect is the message/reaction itself, which
63
+ * frontends already surface as first-class output. Kept separate from
64
+ * `TURN_TERMINATOR_NAMES` — `send_message` delivers without ending the
65
+ * turn, and a future terminator need not be a delivery tool.
66
+ */
67
+ const DELIVERY_TOOL_NAMES: ReadonlySet<string> = new Set(
68
+ ALL_TOOLS.filter((t) => t.delivery).map((t) => t.name),
69
+ );
70
+
58
71
  /**
59
72
  * All tool names registered in Talon's tool catalog. Used by
60
73
  * `stripMcpPrefix` to recognise the bare name when a backend prefixes
@@ -139,6 +152,24 @@ export function isTurnTerminator(
139
152
  return true;
140
153
  }
141
154
 
155
+ /**
156
+ * Whether a tool call by this name is reply-delivery plumbing
157
+ * (`delivery: true` on its definition) — `end_turn`, `send`,
158
+ * `send_message`, `react`, … Its effect reaches the user as a chat
159
+ * message or reaction, so activity timelines ("what the model did")
160
+ * must exclude it or the reply gets double-reported as work.
161
+ *
162
+ * Accepts bare names (`end_turn`) and every prefixed form
163
+ * `stripMcpPrefix` understands (`mcp__desktop-tools__end_turn`,
164
+ * Kilo's `desktop-tools_end_turn`).
165
+ */
166
+ export function isDeliveryTool(toolName: string): boolean {
167
+ return (
168
+ DELIVERY_TOOL_NAMES.has(toolName) ||
169
+ DELIVERY_TOOL_NAMES.has(stripMcpPrefix(toolName))
170
+ );
171
+ }
172
+
142
173
  /** Filter options for composing a tool set. */
143
174
  export interface ComposeOptions {
144
175
  /** Include only tools available on this frontend. */
@@ -0,0 +1,71 @@
1
+ import { z } from "zod";
2
+ import type { ToolDefinition } from "./types.js";
3
+
4
+ /**
5
+ * Device-mesh tools. Deliberately NOT frontend-restricted: the mesh is
6
+ * daemon-wide state served by shared gateway actions, so the model can see,
7
+ * locate, and command companion devices from any chat surface.
8
+ */
9
+
10
+ /** Shared device-target parameter: id or name fragment; default = mobile. */
11
+ const deviceParam = z
12
+ .string()
13
+ .optional()
14
+ .describe(
15
+ "Device id or part of the device name (see list_devices). Defaults to the most recently seen mobile mesh device.",
16
+ );
17
+
18
+ export const meshTools: ToolDefinition[] = [
19
+ {
20
+ name: "list_devices",
21
+ description:
22
+ "List all Talon companion mesh devices with id, platform, online presence, last-seen age, battery state, last-known location, and supported commands.",
23
+ schema: {},
24
+ execute: (_params, bridge) => bridge("list_devices", {}),
25
+ tag: "mesh",
26
+ },
27
+ {
28
+ name: "get_device_location",
29
+ description:
30
+ "Get any Talon companion device's current or last-known location. Sends an on-demand locate request first, waits briefly for a fresh GPS fix, then falls back to last-known. With no device, uses the most recently seen mobile device.",
31
+ schema: { device: deviceParam },
32
+ execute: (params, bridge) => bridge("get_device_location", params),
33
+ tag: "mesh",
34
+ },
35
+ {
36
+ name: "get_device_history",
37
+ description:
38
+ "Movement and battery history for a Talon companion device: a timeline of its reported locations over a window (default 24h, max 168h), with distance traveled and battery trend. Answers questions like where a device was earlier or how fast its battery is draining.",
39
+ schema: {
40
+ device: deviceParam,
41
+ hours: z
42
+ .number()
43
+ .optional()
44
+ .describe("History window in hours (1-168, default 24)."),
45
+ },
46
+ execute: (params, bridge) => bridge("get_device_history", params),
47
+ tag: "mesh",
48
+ },
49
+ {
50
+ name: "ring_device",
51
+ description:
52
+ "Make a Talon companion device ring/vibrate so it can be found (find-my-phone). Optionally include a short message the device may display.",
53
+ schema: {
54
+ device: deviceParam,
55
+ message: z
56
+ .string()
57
+ .optional()
58
+ .describe("Optional short note to show on the device."),
59
+ },
60
+ execute: (params, bridge) => bridge("ring_device", params),
61
+ tag: "mesh",
62
+ },
63
+ {
64
+ name: "get_device_status",
65
+ description:
66
+ "Get live status straight from a Talon companion device: battery and charging state, platform/OS details, app version, and mesh sharing settings.",
67
+ schema: { device: deviceParam },
68
+ execute: (params, bridge) => bridge("get_device_status", params),
69
+ tag: "mesh",
70
+ },
71
+ ];
@@ -113,6 +113,7 @@ Notes:
113
113
  frontends: ["telegram", "teams", "discord", "native"],
114
114
  tag: "messaging",
115
115
  endsTurn: true,
116
+ delivery: true,
116
117
  },
117
118
 
118
119
  // ── Telegram unified send ─────────────────────────────────────────────
@@ -372,6 +373,7 @@ Examples:
372
373
  },
373
374
  frontends: ["telegram", "discord"],
374
375
  tag: "messaging",
376
+ delivery: true,
375
377
  },
376
378
 
377
379
  // ── Native send_message ────────────────────────────────────────────────
@@ -388,6 +390,7 @@ Examples:
388
390
  execute: (params, bridge) => bridge("send_message", params),
389
391
  frontends: ["teams", "native"],
390
392
  tag: "messaging",
393
+ delivery: true,
391
394
  },
392
395
 
393
396
  // ── Native send_message_with_buttons ──────────────────────────────────
@@ -412,6 +415,7 @@ Example: send_message_with_buttons(text="Choose:", rows=[[{"text":"Docs","url":"
412
415
  execute: (params, bridge) => bridge("send_message_with_buttons", params),
413
416
  frontends: ["teams", "native"],
414
417
  tag: "messaging",
418
+ delivery: true,
415
419
  },
416
420
 
417
421
  // ── react ─────────────────────────────────────────────────────────────
@@ -469,6 +473,7 @@ Valid emoji: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🤬 😢 🎉
469
473
  frontends: ["telegram", "discord", "native"],
470
474
  tag: "messaging",
471
475
  endsTurn: true,
476
+ delivery: true,
472
477
  },
473
478
 
474
479
  // ── edit_message ──────────────────────────────────────────────────────
@@ -26,7 +26,8 @@ export type ToolTag =
26
26
  | "skills"
27
27
  | "web"
28
28
  | "admin"
29
- | "models";
29
+ | "models"
30
+ | "mesh";
30
31
 
31
32
  /** The bridge caller signature — injected into execute(). */
32
33
  export type BridgeFunction = (
@@ -78,4 +79,15 @@ export interface ToolDefinition {
78
79
  * is the shared declarative signal, not the implementation.
79
80
  */
80
81
  readonly endsTurn?: boolean;
82
+
83
+ /**
84
+ * Reply-delivery plumbing: the tool's observable effect IS the
85
+ * message/reaction the user receives, which every frontend already
86
+ * surfaces as first-class output (a chat message, a reaction). Tools
87
+ * flagged here are excluded from activity timelines — the tool
88
+ * events a frontend shows or persists as "what the model did" —
89
+ * because reporting them there double-counts the reply as work.
90
+ * Declared on the definition so consumers never classify by name.
91
+ */
92
+ readonly delivery?: boolean;
81
93
  }
@@ -1,9 +1,16 @@
1
1
  export { Thread, type Warp, type ThreadSnapshot } from "./thread.js";
2
2
  export { ThreadSession, type SessionSummary } from "./thread-session.js";
3
- export { Loom } from "./loom.js";
3
+ export { Loom, type ContextRegistry } from "./loom.js";
4
+ export { carryTurnEvents, type EventSink } from "./shuttle.js";
5
+ export { startTypingLoop, TYPING_REFRESH_MS } from "./typing-loop.js";
6
+ export { prefetchMemory } from "./memory-prefetch.js";
7
+ export {
8
+ resolveWarp,
9
+ type WarpResolution,
10
+ type WarpResolverDeps,
11
+ } from "./warp-resolver.js";
4
12
  export {
5
13
  Weaver,
6
- getWeaver,
7
14
  getActiveLoom,
8
15
  initWeaver,
9
16
  type WeaverDeps,
@@ -1,4 +1,30 @@
1
- import { Thread } from "./thread.js";
1
+ import { Thread, type ThreadSnapshot } from "./thread.js";
2
+
3
+ /**
4
+ * The context-tracking face of the Loom — everything the gateway needs
5
+ * to bracket turns and answer numeric-keyed queries, and nothing else.
6
+ * The gateway depends on this interface (not the concrete Loom), so its
7
+ * contract with the registry is explicit and a test can substitute a
8
+ * stub without dragging in Thread creation or eviction.
9
+ */
10
+ export interface ContextRegistry {
11
+ /** Acquire the execution context for a turn. See `Loom.acquireContext`. */
12
+ acquireContext(numericChatId: number, stringId?: string): Thread;
13
+ /** Release one context hold, addressed by numeric or string id. */
14
+ releaseContext(id: number | string): void;
15
+ /** Count one bridge-sent message against the chat's current turn. */
16
+ noteMessageSent(numericChatId: number): void;
17
+ /** Messages the bridge has sent during the chat's current turn. */
18
+ messageCount(numericChatId: number): number;
19
+ /** Whether a turn is currently holding the chat's execution context. */
20
+ hasActiveContext(numericChatId: number): boolean;
21
+ /** Number of chats currently holding an execution context. */
22
+ activeContextCount(): number;
23
+ /** Resolve a string chat id to the numeric id of its active context. */
24
+ numericForStringId(stringId: string): number | null;
25
+ /** Number of live Threads in the registry. */
26
+ size(): number;
27
+ }
2
28
 
3
29
  /**
4
30
  * Loom — the single registry of live chat Threads.
@@ -9,7 +35,7 @@ import { Thread } from "./thread.js";
9
35
  * route inbound tool calls and report active chats without owning any
10
36
  * per-chat state of its own.
11
37
  */
12
- export class Loom {
38
+ export class Loom implements ContextRegistry {
13
39
  private readonly threads = new Map<string, Thread>();
14
40
  private readonly byNumeric = new Map<number, Thread>();
15
41
 
@@ -44,6 +70,11 @@ export class Loom {
44
70
  return [...this.threads.keys()];
45
71
  }
46
72
 
73
+ /** An immutable view of every live Thread. Reading is side-effect free. */
74
+ snapshot(): ThreadSnapshot[] {
75
+ return [...this.threads.values()].map((thread) => thread.describe());
76
+ }
77
+
47
78
  // ── Execution context (gateway-facing; absorbed ChatContext) ────────────────
48
79
 
49
80
  /**
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Memory prefetch (Phase B) — optional pre-retrieval of palace memory
3
+ * for live user messages, strictly fail-closed: a broken palace must
4
+ * never block chat delivery. The result is dynamic turn context passed
5
+ * through ChatRunParams; it never touches the frozen prompt.
6
+ */
7
+
8
+ import type { RetrievedMemory } from "../agent-runtime/capabilities.js";
9
+ import type { MemoryRetriever } from "../memory/retrieval.js";
10
+ import { logDebug, logWarn } from "../../util/log.js";
11
+
12
+ export type PrefetchMemoryInput = {
13
+ chatId: string;
14
+ text: string;
15
+ senderName: string;
16
+ isGroup: boolean;
17
+ /** Request id for log correlation. */
18
+ reqId: string;
19
+ };
20
+
21
+ export async function prefetchMemory(
22
+ retrieve: MemoryRetriever,
23
+ input: PrefetchMemoryInput,
24
+ ): Promise<RetrievedMemory | undefined> {
25
+ try {
26
+ const retrieved = await retrieve({
27
+ runKind: "chat",
28
+ chatId: input.chatId,
29
+ text: input.text,
30
+ senderName: input.senderName,
31
+ isGroup: input.isGroup,
32
+ });
33
+ if (retrieved) {
34
+ logDebug(
35
+ "dispatcher",
36
+ `[${input.reqId}] memory pre-retrieval: ${retrieved.items.length} item(s), ` +
37
+ `${retrieved.items.reduce((n, i) => n + i.text.length, 0)} chars`,
38
+ );
39
+ }
40
+ return retrieved;
41
+ } catch (err) {
42
+ logWarn(
43
+ "dispatcher",
44
+ `[${input.reqId}] memory pre-retrieval failed (running turn without it): ` +
45
+ (err instanceof Error ? err.message : String(err)),
46
+ );
47
+ return undefined;
48
+ }
49
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Shuttle — carries the weft across the warp: consumes a backend's
3
+ * canonical `AgentEvent` stream and forwards every event to the
4
+ * frontend's `onEvent` sink (no callback bridge, no back-translation).
5
+ *
6
+ * The Shuttle owns the three stream-consumption invariants so no caller
7
+ * can get them subtly wrong:
8
+ *
9
+ * - **ordering** — events are awaited in stream order, so a consumer
10
+ * that needs serial delivery gets it;
11
+ * - **ack settlement** — `assistant_message.deliveryAck` is ALWAYS
12
+ * settled, even when no `onEvent` sink is supplied or the sink
13
+ * ignores the event. Otherwise the callback-shaped backend
14
+ * (handler-to-events) blocks forever awaiting delivery
15
+ * confirmation. The frontend's job is just to deliver and throw on
16
+ * failure; the Shuttle maps that onto the ack (resolve on success →
17
+ * block delivered; reject on throw → backend retries, e.g. Codex
18
+ * oversized-message path);
19
+ * - **terminators** — the `completed` event's `AgentResult` is
20
+ * captured for the return value, and an `error` terminator is
21
+ * rethrown as `AgentRunError` so callers' catch paths keep working.
22
+ */
23
+
24
+ import {
25
+ AgentRunError,
26
+ type AgentEvent,
27
+ type AgentResult,
28
+ } from "../agent-runtime/events.js";
29
+
30
+ export type EventSink = (event: AgentEvent) => void | Promise<void>;
31
+
32
+ /**
33
+ * Pump the stream to completion. Returns the `completed` event's
34
+ * result (if the backend emitted one); throws `AgentRunError` when the
35
+ * stream terminates with an `error` event.
36
+ */
37
+ export async function carryTurnEvents(
38
+ stream: AsyncIterable<AgentEvent>,
39
+ onEvent?: EventSink,
40
+ ): Promise<AgentResult | undefined> {
41
+ let agentResult: AgentResult | undefined;
42
+ for await (const event of stream) {
43
+ if (event.type === "completed") {
44
+ agentResult = event.result;
45
+ }
46
+
47
+ if (event.type === "assistant_message" && event.deliveryAck) {
48
+ try {
49
+ await onEvent?.(event);
50
+ event.deliveryAck.resolve();
51
+ } catch (err) {
52
+ event.deliveryAck.reject(err);
53
+ }
54
+ continue;
55
+ }
56
+
57
+ await onEvent?.(event);
58
+ if (event.type === "error") {
59
+ throw new AgentRunError(event.error);
60
+ }
61
+ }
62
+ return agentResult;
63
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Typing loop — keeps the frontend's typing indicator alive for the
3
+ * duration of a turn. Sends immediately, then re-sends on an interval;
4
+ * every send is fail-soft (logged, never thrown) because a dropped
5
+ * indicator must not fail the turn.
6
+ */
7
+
8
+ import { logWarn } from "../../util/log.js";
9
+
10
+ /** Platforms expire typing indicators after ~5s; refresh under that. */
11
+ export const TYPING_REFRESH_MS = 4000;
12
+
13
+ export type SendTyping = (
14
+ numericChatId: number,
15
+ stringId?: string,
16
+ ) => Promise<void>;
17
+
18
+ /** Start the loop. Call the returned function to stop it. */
19
+ export function startTypingLoop(
20
+ sendTyping: SendTyping,
21
+ numericChatId: number,
22
+ stringId: string,
23
+ intervalMs = TYPING_REFRESH_MS,
24
+ ): () => void {
25
+ const send = (label: string) => {
26
+ sendTyping(numericChatId, stringId).catch((err: unknown) => {
27
+ logWarn(
28
+ "dispatcher",
29
+ `${label} failed: ${err instanceof Error ? err.message : String(err)}`,
30
+ );
31
+ });
32
+ };
33
+ send("sendTyping");
34
+ const timer = setInterval(() => send("sendTyping interval"), intervalMs);
35
+ return () => clearInterval(timer);
36
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * WarpResolver — turns an incoming turn into the model/backend binding
3
+ * (the warp) it will run on.
4
+ *
5
+ * Owns the whole resolution ladder so the Weaver never sees a partial
6
+ * state: active-model resolution, the send-time null-model guard, and
7
+ * the per-run override (triggers/cron) with its fall-back-to-chat-model
8
+ * safety. The result is a discriminated union — either a bound warp or
9
+ * a refusal with the user-facing message to deliver — so callers can't
10
+ * forget to handle the "no model" case.
11
+ */
12
+
13
+ import type { ModelRef } from "../agent-runtime/model-ref.js";
14
+ import { logDebug, logWarn } from "../../util/log.js";
15
+
16
+ export type WarpResolverDeps = {
17
+ /**
18
+ * Walks the 5-step active-model resolution chain for the chat. When
19
+ * `model`/`ref` are both `null` (catalog-driven backend with no
20
+ * per-chat pick and no operator default) the turn is refused — see
21
+ * `resolve()`.
22
+ */
23
+ resolveActiveModel: (chatId: string) => Promise<{
24
+ model: string | null;
25
+ ref: ModelRef | null;
26
+ backendId: string;
27
+ }>;
28
+ /**
29
+ * Validates + materialises an explicit per-run model id against the
30
+ * chat's backend. Returns `null` when the id isn't selectable, so
31
+ * the resolver falls back to the chat model. Restricted to the
32
+ * chat's own backend so the session still resumes.
33
+ */
34
+ resolveModelOverride?: (
35
+ chatId: string,
36
+ modelId: string,
37
+ ) => Promise<ModelRef | null>;
38
+ };
39
+
40
+ export type ResolveWarpInput = {
41
+ chatId: string;
42
+ /** Optional per-run model override (triggers/cron). */
43
+ modelOverride?: string;
44
+ /** Turn source, used only for override logging. */
45
+ source: string;
46
+ /** Request id for log correlation. */
47
+ reqId: string;
48
+ };
49
+
50
+ export type WarpResolution =
51
+ | {
52
+ ok: true;
53
+ /** The ModelRef the turn runs on (post-override). */
54
+ ref: ModelRef;
55
+ backendId: string;
56
+ /** A per-run override was applied for this turn. */
57
+ overridden: boolean;
58
+ }
59
+ | {
60
+ ok: false;
61
+ backendId: string;
62
+ /** User-facing refusal ("use /model to pick one"). */
63
+ message: string;
64
+ };
65
+
66
+ export async function resolveWarp(
67
+ deps: WarpResolverDeps,
68
+ input: ResolveWarpInput,
69
+ ): Promise<WarpResolution> {
70
+ const { chatId, modelOverride, source, reqId } = input;
71
+
72
+ // Send-time null-model guard. When the active-model resolver returns
73
+ // no usable model, refuse to call the backend — it would either error
74
+ // opaquely or run on the wrong default.
75
+ const { model, ref, backendId } = await deps.resolveActiveModel(chatId);
76
+ if (model === null || ref === null) {
77
+ logWarn(
78
+ "dispatcher",
79
+ `[${reqId}] refusing query: no model resolved (chat=${chatId}, backend=${backendId})`,
80
+ );
81
+ return {
82
+ ok: false,
83
+ backendId,
84
+ message:
85
+ `No model selected for backend \`${backendId}\`. ` +
86
+ `Use /model to pick one — or set ` +
87
+ `\`backendDefaults.${backendId}\` in talon.json to apply a ` +
88
+ `default for all chats on this backend.`,
89
+ };
90
+ }
91
+
92
+ // Per-run model override (triggers/cron). Resolve against the chat's
93
+ // backend; on success swap the ref for this turn only, on failure fall
94
+ // back to the chat model so a stale override never kills the run.
95
+ let runRef = ref;
96
+ if (modelOverride && deps.resolveModelOverride) {
97
+ try {
98
+ const overrideRef = await deps.resolveModelOverride(
99
+ chatId,
100
+ modelOverride,
101
+ );
102
+ if (overrideRef) {
103
+ runRef = overrideRef;
104
+ logDebug(
105
+ "dispatcher",
106
+ `[${reqId}] model override → ${modelOverride} (${source})`,
107
+ );
108
+ } else {
109
+ logWarn(
110
+ "dispatcher",
111
+ `[${reqId}] model override "${modelOverride}" not resolvable on backend ${backendId}; using chat model`,
112
+ );
113
+ }
114
+ } catch (err) {
115
+ logWarn(
116
+ "dispatcher",
117
+ `[${reqId}] model override resolution threw: ${err instanceof Error ? err.message : String(err)}; using chat model`,
118
+ );
119
+ }
120
+ }
121
+
122
+ return { ok: true, ref: runRef, backendId, overridden: runRef !== ref };
123
+ }