talon-agent 3.20.0 → 3.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.20.0",
3
+ "version": "3.21.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -102,7 +102,7 @@
102
102
  "@grammyjs/transformer-throttler": "^1.2.1",
103
103
  "@kilocode/sdk": "^7.2.22",
104
104
  "@modelcontextprotocol/sdk": "^1.29.0",
105
- "@openai/agents": "^0.14.0",
105
+ "@openai/agents": "^0.16.0",
106
106
  "@openai/codex-sdk": "^0.147.0",
107
107
  "@opencode-ai/sdk": "^1.17.4",
108
108
  "@playwright/mcp": "0.0.79",
@@ -117,7 +117,7 @@
117
117
  "liquidjs": "^10.27.0",
118
118
  "marked": "^18.0.0",
119
119
  "mem0ai": "^3.0.13",
120
- "openai": "^6.46.0",
120
+ "openai": "^7.5.0",
121
121
  "p-retry": "^8.0.0",
122
122
  "picocolors": "^1.1.1",
123
123
  "pino": "^10.3.1",
package/prompts/base.md CHANGED
@@ -1,12 +1,6 @@
1
1
  Be concise and direct. Lead with the substance, keep it as short as the moment needs, and stop when it's said.
2
2
 
3
- ## Conversation
4
-
5
- - Answer first; context and caveats after, and only when they change something for the reader.
6
- - Let depth follow the question: a quick ask gets a line or two, a real problem gets real work. When unsure, start small — people ask for more when they want it.
7
- - Chat is not a document. Plain sentences usually beat headings, bullet cascades, and closing summaries; reach for structure only when it genuinely clarifies.
8
- - Don't narrate your process or pad with filler — do the thing, then share what matters.
9
- - A short clarifying question beats a long answer to the wrong question, but only when the ambiguity is real.
3
+ How you talk — voice, stances, what never to say — is the Identity section above. It applies on every backend and isn't repeated here.
10
4
 
11
5
  ## Tools
12
6
 
@@ -1,6 +1,6 @@
1
1
  ## Who you are
2
2
 
3
- You're a Talon agent — a peer with tools, not a service desk. The model and tools available to you depend on the active backend; only the tools listed below this prompt actually exist for this run. Tools for talking to your current platform (send, react, and the rest) are always provided by the frontend.
3
+ You're a Talon agent — a peer with tools, not a service desk. People talk to you all day; be someone worth talking to. The model and tools available to you depend on the active backend; only the tools listed below this prompt actually exist for this run. Tools for talking to your current platform (send, react, and the rest) are always provided by the frontend.
4
4
 
5
5
  ## Voice
6
6
 
@@ -10,13 +10,15 @@ Length follows the question, not habit: a quick ask gets a line or two, a real p
10
10
 
11
11
  Have opinions and give reasons. "I'd use X, because Y" beats five options with no recommendation.
12
12
 
13
- Match the room. Casual chat gets casual replies, technical questions get precise answers, and a tense thread doesn't need you adding heat. Follow up on what's genuinely interesting — not out of habit.
13
+ Engage with what was actually said, not the generic shape of it. The detail someone mentions in passing is often the interesting part; picking it up is the difference between a conversation and a ticket.
14
14
 
15
- Be expressive where the platform allows — emoji, reactions, stickers, humour — as seasoning, not the meal.
15
+ Match the room. Casual chat gets casual replies, technical questions get precise answers, and a tense thread doesn't need you adding heat. Humour, emoji, reactions, stickers — wherever the platform has them, use them the way a person would: as seasoning, not the meal.
16
+
17
+ Sound like yourself. Plain words, contractions, the occasional aside. A reply that reads like a person thinking beats one that reads like a product performing.
16
18
 
17
19
  ## Stances
18
20
 
19
- Situations are what define a voice. Take these positions.
21
+ Voice shows up in the awkward moments. Take these positions.
20
22
 
21
23
  **Their plan is bad.** Say what's wrong in a sentence or two, then do the work as asked. Don't refuse to engage, don't lecture, and don't quietly do it a different way instead.
22
24
 
@@ -30,7 +32,7 @@ Situations are what define a voice. Take these positions.
30
32
 
31
33
  **The request is ambiguous.** Make the call a careful colleague would make, and say which call you made. Ask only when different readings would mean materially different work.
32
34
 
33
- **You have nothing to add.** Then don't add it. "ok", "thanks", "lol" want a reaction or silence, not a reply. In groups you're a participant, not a host — don't answer for other people, and let conversations that aren't about you flow past.
35
+ **You have nothing to add.** Then don't. "ok", "thanks", "lol" want a reaction or silence, not a reply. In groups you're a participant, not a host — don't answer for other people, and let conversations that aren't about you flow past.
34
36
 
35
37
  ## Never
36
38
 
package/src/bootstrap.ts CHANGED
@@ -415,6 +415,14 @@ export async function initBackendAndDispatcher(
415
415
  );
416
416
  return { model, backendId: beId };
417
417
  },
418
+ // ...and when that ambient backend turns out to be one that can't host an
419
+ // isolated run (e.g. the chat was switched to a provider with no background
420
+ // capability), the job reruns on the heartbeat role backend instead of
421
+ // being skipped.
422
+ resolveJobFallback: () => ({
423
+ backendId: config.heartbeatBackend ?? config.backend,
424
+ model: config.heartbeatModel ?? config.model ?? null,
425
+ }),
418
426
  });
419
427
  initTriggers({ execute: dispatcherExecute });
420
428
  resumeTriggersAfterRestart().catch((err) =>
@@ -62,6 +62,13 @@ type CronDeps = {
62
62
  resolveChatModel: (
63
63
  chatId: string,
64
64
  ) => Promise<{ model: string | null; backendId: string }>;
65
+ /**
66
+ * Resolve the deployment's background-capable role backend (heartbeat, else
67
+ * the configured default). Used as a fallback for query jobs that inherited
68
+ * the chat's ambient backend and found it can't run isolated jobs — a
69
+ * `/model` switch in the chat shouldn't silently disable the schedule.
70
+ */
71
+ resolveJobFallback?: () => { model: string | null; backendId: string };
65
72
  };
66
73
 
67
74
  let deps: CronDeps | null = null;
@@ -473,6 +480,10 @@ export async function executeJob(job: CronJob): Promise<ExecuteJobResult> {
473
480
  // chat's backend + active model.
474
481
  let backendId: string;
475
482
  let model: string | null;
483
+ // A job that pinned its own provider is honoured as written — no fallback.
484
+ // One that inherited the chat's ambient backend gets a safety net, because
485
+ // that backend can change under it at any time (`/model`, a rebind).
486
+ let fallback: { backendId: string; model: string } | undefined;
476
487
  if (job.provider) {
477
488
  backendId = job.provider;
478
489
  model = job.model ?? null;
@@ -480,6 +491,10 @@ export async function executeJob(job: CronJob): Promise<ExecuteJobResult> {
480
491
  const chat = await deps.resolveChatModel(job.chatId);
481
492
  backendId = chat.backendId;
482
493
  model = job.model ?? chat.model;
494
+ const candidate = deps.resolveJobFallback?.();
495
+ if (candidate?.model) {
496
+ fallback = { backendId: candidate.backendId, model: candidate.model };
497
+ }
483
498
  }
484
499
  if (!model) {
485
500
  throw new Error(
@@ -502,6 +517,7 @@ export async function executeJob(job: CronJob): Promise<ExecuteJobResult> {
502
517
  label: job.name,
503
518
  kind: "cron",
504
519
  timeoutMs: CRON_JOB_TIMEOUT_MS,
520
+ ...(fallback ? { fallback } : {}),
505
521
  });
506
522
  if (result.status === "skipped") {
507
523
  await deps.sendMessage(
@@ -51,6 +51,19 @@ export interface JobOneShotParams {
51
51
  readonly kind: JobKind;
52
52
  /** Optional override of the hard timeout. */
53
53
  readonly timeoutMs?: number;
54
+ /**
55
+ * Backend to retry on when the primary one can't host an isolated run.
56
+ *
57
+ * A cron `query` job with no provider override inherits the chat's *ambient*
58
+ * backend, which any `/model` switch can change out from under it — including
59
+ * to a provider with no background capability (or one where the chat's model
60
+ * isn't selectable). That has nothing to do with the job, so rather than skip
61
+ * it, fall back to the deployment's background-capable role backend
62
+ * (heartbeat, else the configured default). Omitted for jobs that pinned
63
+ * their own provider: an explicit choice is honoured or skipped, never
64
+ * silently rerouted.
65
+ */
66
+ readonly fallback?: { readonly backendId: string; readonly model: string };
54
67
  }
55
68
 
56
69
  export type JobOneShotResult =
@@ -75,7 +88,17 @@ async function openJobLog(
75
88
  return appendLog;
76
89
  }
77
90
 
78
- function skipJob(params: JobOneShotParams, reason: string): JobOneShotResult {
91
+ // The warning is deferred to runJobOneShot: an attempt that fails here may
92
+ // still succeed on the fallback backend, and a run that ultimately ran must
93
+ // not leave a "skipped" warning in the logs.
94
+ function skipJob(reason: string): JobOneShotResult {
95
+ return { status: "skipped", reason };
96
+ }
97
+
98
+ function reportSkip(
99
+ params: JobOneShotParams,
100
+ reason: string,
101
+ ): JobOneShotResult {
79
102
  logWarn(
80
103
  params.kind === "cron" ? "cron" : "triggers",
81
104
  `isolated job "${params.label}" skipped: ${reason}`,
@@ -84,19 +107,20 @@ function skipJob(params: JobOneShotParams, reason: string): JobOneShotResult {
84
107
  }
85
108
 
86
109
  /**
87
- * Run a trigger/cron wake-up as an isolated one-shot on a different backend.
88
- * Acquires the target backend on demand and always releases it afterwards.
110
+ * One attempt on one backend: acquire it, check it can actually host an
111
+ * isolated run, execute, and always release it afterwards.
89
112
  */
90
- export async function runJobOneShot(
113
+ async function attemptJobOneShot(
91
114
  params: JobOneShotParams,
115
+ backendId: string,
116
+ model: string,
92
117
  ): Promise<JobOneShotResult> {
93
118
  let acquired: Awaited<ReturnType<typeof acquireBackendInstance>>;
94
119
  try {
95
- acquired = await acquireBackendInstance(params.backendId);
120
+ acquired = await acquireBackendInstance(backendId);
96
121
  } catch (err) {
97
122
  return skipJob(
98
- params,
99
- `provider "${params.backendId}" is unavailable: ${err instanceof Error ? err.message : String(err)}`,
123
+ `provider "${backendId}" is unavailable: ${err instanceof Error ? err.message : String(err)}`,
100
124
  );
101
125
  }
102
126
 
@@ -105,32 +129,29 @@ export async function runJobOneShot(
105
129
  const background = backend.background;
106
130
  if (!background) {
107
131
  return skipJob(
108
- params,
109
- `provider "${params.backendId}" can't run isolated jobs (no background capability).`,
132
+ `provider "${backendId}" can't run isolated jobs (no background capability).`,
110
133
  );
111
134
  }
112
135
 
113
136
  let modelValid = false;
114
137
  try {
115
- modelValid = await isModelValidForBackend(backend, params.model);
138
+ modelValid = await isModelValidForBackend(backend, model);
116
139
  } catch (err) {
117
140
  return skipJob(
118
- params,
119
- `could not validate model "${params.model}" on provider "${params.backendId}": ${err instanceof Error ? err.message : String(err)}`,
141
+ `could not validate model "${model}" on provider "${backendId}": ${err instanceof Error ? err.message : String(err)}`,
120
142
  );
121
143
  }
122
144
  if (!modelValid) {
123
145
  return skipJob(
124
- params,
125
- `model "${params.model}" is not selectable on provider "${params.backendId}".`,
146
+ `model "${model}" is not selectable on provider "${backendId}".`,
126
147
  );
127
148
  }
128
149
 
129
150
  const appendLog = await openJobLog(
130
151
  params.kind,
131
152
  params.label,
132
- params.backendId,
133
- params.model,
153
+ backendId,
154
+ model,
134
155
  );
135
156
 
136
157
  const abortController = new AbortController();
@@ -140,7 +161,7 @@ export async function runJobOneShot(
140
161
  chatId: params.chatId,
141
162
  abort: () => abortController.abort(),
142
163
  });
143
- task.bind({ model: params.model, backendId: params.backendId });
164
+ task.bind({ model, backendId });
144
165
 
145
166
  const oneShot: OneShotAgentParams = {
146
167
  prompt: params.payload,
@@ -151,7 +172,7 @@ export async function runJobOneShot(
151
172
  ),
152
173
  contextLabel: JOB_CONTEXT_LABEL,
153
174
  workspace: dirs.workspace,
154
- model: params.model,
175
+ model,
155
176
  abortController,
156
177
  appendLog,
157
178
  };
@@ -172,10 +193,49 @@ export async function runJobOneShot(
172
193
  }
173
194
  log(
174
195
  params.kind === "cron" ? "cron" : "triggers",
175
- `isolated job "${params.label}" ran on ${params.backendId}/${params.model}`,
196
+ `isolated job "${params.label}" ran on ${backendId}/${model}`,
176
197
  );
177
198
  return { status: "ran" };
178
199
  } finally {
179
200
  await release();
180
201
  }
181
202
  }
203
+
204
+ /**
205
+ * Run a trigger/cron wake-up as an isolated one-shot.
206
+ *
207
+ * Tries the requested backend first. When that backend can't host the run at
208
+ * all — no background capability, unavailable, or the inherited model isn't
209
+ * selectable on it — and a distinct {@link JobOneShotParams.fallback} was
210
+ * supplied, the job is retried there instead of being skipped. A job is only
211
+ * reported as skipped once every candidate has been ruled out.
212
+ */
213
+ export async function runJobOneShot(
214
+ params: JobOneShotParams,
215
+ ): Promise<JobOneShotResult> {
216
+ const first = await attemptJobOneShot(params, params.backendId, params.model);
217
+ if (first.status === "ran") return first;
218
+
219
+ const { fallback } = params;
220
+ if (
221
+ !fallback ||
222
+ (fallback.backendId === params.backendId && fallback.model === params.model)
223
+ ) {
224
+ return reportSkip(params, first.reason);
225
+ }
226
+
227
+ log(
228
+ params.kind === "cron" ? "cron" : "triggers",
229
+ `isolated job "${params.label}": ${params.backendId}/${params.model} can't host it (${first.reason}) — retrying on ${fallback.backendId}/${fallback.model}`,
230
+ );
231
+ const second = await attemptJobOneShot(
232
+ params,
233
+ fallback.backendId,
234
+ fallback.model,
235
+ );
236
+ if (second.status === "ran") return second;
237
+ return reportSkip(
238
+ params,
239
+ `${first.reason} Fallback ${fallback.backendId}/${fallback.model} also unusable: ${second.reason}`,
240
+ );
241
+ }