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 +3 -3
- package/prompts/base.md +1 -7
- package/prompts/identity.md +7 -5
- package/src/bootstrap.ts +8 -0
- package/src/core/background/cron.ts +16 -0
- package/src/core/background/job-oneshot.ts +79 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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": "^
|
|
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
|
-
|
|
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
|
|
package/prompts/identity.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
88
|
-
*
|
|
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
|
-
|
|
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(
|
|
120
|
+
acquired = await acquireBackendInstance(backendId);
|
|
96
121
|
} catch (err) {
|
|
97
122
|
return skipJob(
|
|
98
|
-
|
|
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
|
-
|
|
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,
|
|
138
|
+
modelValid = await isModelValidForBackend(backend, model);
|
|
116
139
|
} catch (err) {
|
|
117
140
|
return skipJob(
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
|
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
|
|
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 ${
|
|
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
|
+
}
|