talon-agent 3.19.1 → 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 +5 -5
- 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/src/core/engine/dispatcher.ts +9 -0
- package/src/core/tasks/table.ts +18 -0
- package/src/frontend/discord/commands/definitions.ts +4 -0
- package/src/frontend/discord/commands/info.ts +2 -1
- package/src/frontend/discord/commands/router.ts +3 -1
- package/src/frontend/discord/commands/session.ts +13 -0
- package/src/frontend/telegram/commands/definitions.ts +1 -0
- package/src/frontend/telegram/commands/info.ts +2 -1
- package/src/frontend/telegram/commands/session.ts +8 -0
- package/src/frontend/telegram/userbot.ts +15 -2
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,10 +102,10 @@
|
|
|
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.
|
|
106
|
-
"@openai/codex-sdk": "^0.
|
|
105
|
+
"@openai/agents": "^0.16.0",
|
|
106
|
+
"@openai/codex-sdk": "^0.147.0",
|
|
107
107
|
"@opencode-ai/sdk": "^1.17.4",
|
|
108
|
-
"@playwright/mcp": "0.0.
|
|
108
|
+
"@playwright/mcp": "0.0.79",
|
|
109
109
|
"@types/cross-spawn": "^6.0.6",
|
|
110
110
|
"big-integer": "^1.6.52",
|
|
111
111
|
"cheerio": "^1.2.0",
|
|
@@ -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
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { ExecuteParams, ExecuteResult } from "../types.js";
|
|
12
12
|
import { log } from "../../util/log.js";
|
|
13
|
+
import { taskTable, type KillOutcome } from "../tasks/index.js";
|
|
13
14
|
import {
|
|
14
15
|
initWeaver,
|
|
15
16
|
type Weaver,
|
|
@@ -57,6 +58,14 @@ export function snapshot(): ThreadSnapshot[] {
|
|
|
57
58
|
return weaver?.snapshot() ?? [];
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Stop only this chat's active turn. A later message already waiting in the
|
|
63
|
+
* per-chat FIFO is intentionally left alone and runs after the stop settles.
|
|
64
|
+
*/
|
|
65
|
+
export function stopCurrentTurn(chatId: string): KillOutcome {
|
|
66
|
+
return taskTable.killRunningTurn(chatId);
|
|
67
|
+
}
|
|
68
|
+
|
|
60
69
|
/**
|
|
61
70
|
* Execute an AI query with full lifecycle management.
|
|
62
71
|
* Same-chat queries are serialized (FIFO) to avoid session conflicts.
|
package/src/core/tasks/table.ts
CHANGED
|
@@ -122,6 +122,24 @@ export class TaskTable {
|
|
|
122
122
|
return { ok: true };
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Request an abort for the turn currently running in one chat. Queued turns
|
|
127
|
+
* are deliberately ignored: `/stop` means "stop what is happening now",
|
|
128
|
+
* never "discard my next message".
|
|
129
|
+
*/
|
|
130
|
+
killRunningTurn(chatId: string): KillOutcome {
|
|
131
|
+
for (const [id, task] of this.live) {
|
|
132
|
+
if (
|
|
133
|
+
task.record.kind === "turn" &&
|
|
134
|
+
task.record.chatId === chatId &&
|
|
135
|
+
task.record.state === "running"
|
|
136
|
+
) {
|
|
137
|
+
return this.kill(id);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { ok: false, reason: "not-found" };
|
|
141
|
+
}
|
|
142
|
+
|
|
125
143
|
/**
|
|
126
144
|
* Every live task plus the bounded settled history, id-ascending.
|
|
127
145
|
* Snapshots are copies — callers can never mutate table state.
|
|
@@ -82,6 +82,10 @@ export function buildCommandDefinitions(devBuild = false): unknown[] {
|
|
|
82
82
|
.setName("reset")
|
|
83
83
|
.setDescription("Clear session and start fresh")
|
|
84
84
|
.toJSON(),
|
|
85
|
+
new SlashCommandBuilder()
|
|
86
|
+
.setName("stop")
|
|
87
|
+
.setDescription("Stop the current response")
|
|
88
|
+
.toJSON(),
|
|
85
89
|
new SlashCommandBuilder()
|
|
86
90
|
.setName("restart")
|
|
87
91
|
.setDescription("Restart the bot (admin only)")
|
|
@@ -31,7 +31,7 @@ export async function handleStart(
|
|
|
31
31
|
"",
|
|
32
32
|
"Mention me, reply to me, or DM me. Attach files, photos, voice notes — I'll read them.",
|
|
33
33
|
"",
|
|
34
|
-
"/status /reset /help",
|
|
34
|
+
"/status /stop /reset /help",
|
|
35
35
|
].join("\n"),
|
|
36
36
|
);
|
|
37
37
|
}
|
|
@@ -54,6 +54,7 @@ export async function handleHelp(
|
|
|
54
54
|
"",
|
|
55
55
|
"**Session**",
|
|
56
56
|
" /status — session info, usage, and stats",
|
|
57
|
+
" /stop — stop the current response",
|
|
57
58
|
" /metrics — aggregate performance metrics (admin)",
|
|
58
59
|
" /dream — force memory consolidation now (admin)",
|
|
59
60
|
" /ping — health check with latency",
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
handleMesh,
|
|
31
31
|
handleUsage,
|
|
32
32
|
} from "./info.js";
|
|
33
|
-
import { handleReset, handleStatus } from "./session.js";
|
|
33
|
+
import { handleReset, handleStatus, handleStop } from "./session.js";
|
|
34
34
|
import {
|
|
35
35
|
handleModel,
|
|
36
36
|
handleEffort,
|
|
@@ -143,6 +143,8 @@ async function routeSlashCommand(
|
|
|
143
143
|
return handlePulse(interaction, chatId);
|
|
144
144
|
case "reset":
|
|
145
145
|
return handleReset(interaction, gateway, chatId);
|
|
146
|
+
case "stop":
|
|
147
|
+
return handleStop(interaction, chatId);
|
|
146
148
|
case "restart":
|
|
147
149
|
return handleRestart(interaction);
|
|
148
150
|
case "metrics":
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
performSessionReset,
|
|
24
24
|
collectSessionStatus,
|
|
25
25
|
} from "../../shared/session-status.js";
|
|
26
|
+
import { stopCurrentTurn } from "../../../core/engine/dispatcher.js";
|
|
26
27
|
import { reply } from "./shared.js";
|
|
27
28
|
|
|
28
29
|
export async function handleReset(
|
|
@@ -42,6 +43,18 @@ export async function handleReset(
|
|
|
42
43
|
await reply(i, "Session cleared.", true);
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
export async function handleStop(
|
|
47
|
+
i: ChatInputCommandInteraction,
|
|
48
|
+
chatId: string,
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
const outcome = stopCurrentTurn(chatId);
|
|
51
|
+
await reply(
|
|
52
|
+
i,
|
|
53
|
+
outcome.ok ? "⏹ Stop requested." : "No response is currently running.",
|
|
54
|
+
true,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
45
58
|
export async function handleStatus(
|
|
46
59
|
i: ChatInputCommandInteraction,
|
|
47
60
|
config: TalonConfig,
|
|
@@ -26,6 +26,7 @@ export const TELEGRAM_COMMANDS: ReadonlyArray<{
|
|
|
26
26
|
{ command: "model", description: "Show or change model" },
|
|
27
27
|
{ command: "effort", description: "Set thinking effort level" },
|
|
28
28
|
{ command: "pulse", description: "Conversation engagement settings" },
|
|
29
|
+
{ command: "stop", description: "Stop the current response" },
|
|
29
30
|
{ command: "reset", description: "Clear session and start fresh" },
|
|
30
31
|
{ command: "restart", description: "Restart the bot (admin)" },
|
|
31
32
|
{ command: "usage", description: "Plan limits across every backend" },
|
|
@@ -21,7 +21,7 @@ export function registerInfoCommands(bot: Bot): void {
|
|
|
21
21
|
"Send a message, photo, doc, or voice note.",
|
|
22
22
|
"In groups, @mention or reply to activate.",
|
|
23
23
|
"",
|
|
24
|
-
"/status /reset /help",
|
|
24
|
+
"/status /stop /reset /help",
|
|
25
25
|
].join("\n"),
|
|
26
26
|
{ parse_mode: "HTML" },
|
|
27
27
|
),
|
|
@@ -40,6 +40,7 @@ export function registerInfoCommands(bot: Bot): void {
|
|
|
40
40
|
"",
|
|
41
41
|
"<b>Session</b>",
|
|
42
42
|
" /status -- session info, usage, and stats",
|
|
43
|
+
" /stop -- stop the current response",
|
|
43
44
|
" /metrics -- aggregate performance metrics (admin)",
|
|
44
45
|
" /doctor -- environment and native-module health (admin)",
|
|
45
46
|
" /dream -- force memory consolidation now",
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
performSessionReset,
|
|
21
21
|
collectSessionStatus,
|
|
22
22
|
} from "../../shared/session-status.js";
|
|
23
|
+
import { stopCurrentTurn } from "../../../core/engine/dispatcher.js";
|
|
23
24
|
import type { RegisterDeps } from "./state.js";
|
|
24
25
|
|
|
25
26
|
export function registerSessionCommands(
|
|
@@ -34,6 +35,13 @@ export function registerSessionCommands(
|
|
|
34
35
|
await ctx.reply("Session cleared.");
|
|
35
36
|
});
|
|
36
37
|
|
|
38
|
+
bot.command("stop", async (ctx) => {
|
|
39
|
+
const outcome = stopCurrentTurn(String(ctx.chat.id));
|
|
40
|
+
await ctx.reply(
|
|
41
|
+
outcome.ok ? "⏹ Stop requested." : "No response is currently running.",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
37
45
|
bot.command("status", async (ctx) => {
|
|
38
46
|
const cid = String(ctx.chat.id);
|
|
39
47
|
const s = await collectSessionStatus(
|
|
@@ -134,10 +134,23 @@ function startConnectionMonitor(): void {
|
|
|
134
134
|
if (reconnectTimer) return;
|
|
135
135
|
reconnectTimer = setInterval(async () => {
|
|
136
136
|
if (!client) return;
|
|
137
|
-
if (client.connected) return;
|
|
138
137
|
if (reconnecting) return; // prevent overlapping reconnect attempts
|
|
139
|
-
reconnecting = true;
|
|
140
138
|
|
|
139
|
+
// GramJS keeps `client.connected === true` even after the underlying
|
|
140
|
+
// socket has silently died, so a real round-trip is the only reliable
|
|
141
|
+
// liveness signal. Without it the userbot can go stale for hours/days
|
|
142
|
+
// while the monitor believes it is still connected.
|
|
143
|
+
if (client.connected) {
|
|
144
|
+
try {
|
|
145
|
+
// A full API round-trip; throws if the socket is silently dead.
|
|
146
|
+
await client.getMe();
|
|
147
|
+
return; // genuinely alive
|
|
148
|
+
} catch {
|
|
149
|
+
logWarn("userbot", "Liveness check failed — treating socket as dead.");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
reconnecting = true;
|
|
141
154
|
logWarn("userbot", "Connection lost, attempting reconnect...");
|
|
142
155
|
try {
|
|
143
156
|
await client.connect();
|