typeclaw 0.12.0 → 0.14.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 +1 -1
- package/scripts/dump-system-prompt.ts +12 -11
- package/src/agent/index.ts +15 -22
- package/src/agent/loop-guard.ts +170 -0
- package/src/agent/model-fallback.ts +2 -1
- package/src/agent/multimodal/index.ts +1 -1
- package/src/agent/multimodal/look-at.ts +118 -55
- package/src/agent/plugin-tools.ts +57 -0
- package/src/agent/subagents.ts +2 -1
- package/src/agent/system-prompt.ts +39 -26
- package/src/agent/tools/channel-fetch-attachment.ts +45 -16
- package/src/agent/tools/normalize-ref.ts +11 -0
- package/src/agent/tools/skip-response.ts +24 -32
- package/src/agent/tools/spawn-subagent.ts +2 -0
- package/src/bundled-plugins/reviewer/index.ts +11 -0
- package/src/bundled-plugins/reviewer/reviewer.ts +171 -0
- package/src/bundled-plugins/reviewer/skills/code-review.ts +73 -0
- package/src/bundled-plugins/reviewer/skills/general.ts +68 -0
- package/src/channels/adapters/discord-bot-classify.ts +32 -24
- package/src/channels/adapters/github/inbound.ts +63 -7
- package/src/channels/adapters/github/index.ts +32 -0
- package/src/channels/adapters/kakaotalk-attachment.ts +140 -133
- package/src/channels/adapters/kakaotalk-classify.ts +8 -1
- package/src/channels/adapters/kakaotalk.ts +19 -11
- package/src/channels/adapters/slack-bot-classify.ts +30 -14
- package/src/channels/adapters/slack-bot.ts +3 -2
- package/src/channels/adapters/telegram-bot-classify.ts +36 -13
- package/src/channels/adapters/telegram-bot.ts +3 -3
- package/src/channels/outbound-flood-filter.ts +57 -0
- package/src/channels/router.ts +114 -15
- package/src/channels/types.ts +52 -1
- package/src/cli/builtins.ts +1 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/mount.ts +157 -0
- package/src/cli/update.ts +6 -4
- package/src/config/mounts-mutation.ts +161 -0
- package/src/doctor/channel-checks.ts +328 -0
- package/src/doctor/checks.ts +2 -0
- package/src/init/dockerfile.ts +24 -7
- package/src/init/hatching.ts +1 -1
- package/src/plugin/index.ts +6 -0
- package/src/plugin/load-skill.ts +99 -0
- package/src/run/bundled-plugins.ts +2 -0
- package/src/run/index.ts +31 -1
- package/src/secrets/claude-credentials-json.ts +129 -0
- package/src/secrets/codex-auth-json.ts +67 -0
- package/src/secrets/export-claude-credentials-file.ts +279 -0
- package/src/secrets/export-codex-auth-file.ts +243 -0
- package/src/secrets/index.ts +16 -0
- package/src/server/command-runner.ts +2 -1
- package/src/server/index.ts +3 -2
- package/src/shared/index.ts +7 -1
- package/src/shared/local-time.ts +32 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +47 -13
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +10 -11
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +8 -0
- package/src/skills/typeclaw-claude-code/SKILL.md +5 -4
- package/src/skills/typeclaw-claude-code/references/auth-flow.md +35 -0
- package/src/skills/typeclaw-codex-cli/SKILL.md +2 -1
- package/src/skills/typeclaw-codex-cli/references/auth-flow.md +22 -0
- package/src/skills/typeclaw-kaomoji/SKILL.md +116 -0
- package/src/update/index.ts +95 -26
|
@@ -31,11 +31,19 @@ import type {
|
|
|
31
31
|
ToolResult,
|
|
32
32
|
} from '@/plugin'
|
|
33
33
|
|
|
34
|
+
import { createLoopGuard, type LoopGuard } from './loop-guard'
|
|
34
35
|
import { checkImageReadRedirect } from './multimodal/read-redirect'
|
|
35
36
|
import type { SessionOrigin } from './session-origin'
|
|
36
37
|
import { webfetchTool } from './tools/webfetch'
|
|
37
38
|
import { websearchTool } from './tools/websearch'
|
|
38
39
|
|
|
40
|
+
// Process-wide loop guard. State is keyed by sessionId so concurrent sessions
|
|
41
|
+
// don't interfere; the guard's own LRU bound keeps it from growing without
|
|
42
|
+
// limit. Wrappers consult it before invoking the underlying tool so the
|
|
43
|
+
// detector covers every tool category — plugin tools, TypeClaw system tools,
|
|
44
|
+
// and pi-coding-agent builtins — through one chokepoint.
|
|
45
|
+
let sharedLoopGuard: LoopGuard = createLoopGuard()
|
|
46
|
+
|
|
39
47
|
const ACKNOWLEDGE_GUARDS_SCHEMA = Type.Optional(
|
|
40
48
|
Type.Object(
|
|
41
49
|
{
|
|
@@ -177,6 +185,11 @@ export function wrapPluginTool(tool: Tool<any>, opts: WrapToolOptions): ToolDefi
|
|
|
177
185
|
return errorResult(`blocked: ${blockResult.reason}`)
|
|
178
186
|
}
|
|
179
187
|
|
|
188
|
+
const loopDecision = sharedLoopGuard.check(opts.sessionId, opts.toolName, before.args)
|
|
189
|
+
if (loopDecision.kind === 'block') {
|
|
190
|
+
return errorResult(loopDecision.message)
|
|
191
|
+
}
|
|
192
|
+
|
|
180
193
|
const toolCtx: ToolContext = {
|
|
181
194
|
signal,
|
|
182
195
|
sessionId: opts.sessionId,
|
|
@@ -192,6 +205,10 @@ export function wrapPluginTool(tool: Tool<any>, opts: WrapToolOptions): ToolDefi
|
|
|
192
205
|
return errorResult(message)
|
|
193
206
|
}
|
|
194
207
|
|
|
208
|
+
if (loopDecision.kind === 'warn') {
|
|
209
|
+
result = appendLoopWarning(result, loopDecision.message)
|
|
210
|
+
}
|
|
211
|
+
|
|
195
212
|
await opts.hooks.runToolAfter({
|
|
196
213
|
tool: opts.toolName,
|
|
197
214
|
sessionId: opts.sessionId,
|
|
@@ -227,6 +244,10 @@ export function wrapSystemTool<TParams extends TSchema, TDetails = unknown, TSta
|
|
|
227
244
|
if (blockResult !== undefined) {
|
|
228
245
|
throw new Error(`blocked: ${blockResult.reason}`)
|
|
229
246
|
}
|
|
247
|
+
const loopDecision = sharedLoopGuard.check(opts.sessionId, tool.name, mutableArgs)
|
|
248
|
+
if (loopDecision.kind === 'block') {
|
|
249
|
+
throw new Error(loopDecision.message)
|
|
250
|
+
}
|
|
230
251
|
const guardResult = await runFinalWriteGuards({
|
|
231
252
|
tool: tool.name,
|
|
232
253
|
args: mutableArgs,
|
|
@@ -246,6 +267,11 @@ export function wrapSystemTool<TParams extends TSchema, TDetails = unknown, TSta
|
|
|
246
267
|
content: result.content as ContentPart[],
|
|
247
268
|
details: result.details,
|
|
248
269
|
}
|
|
270
|
+
if (loopDecision.kind === 'warn') {
|
|
271
|
+
const warned = appendLoopWarning(hookResult, loopDecision.message)
|
|
272
|
+
hookResult.content = warned.content
|
|
273
|
+
hookResult.details = warned.details
|
|
274
|
+
}
|
|
249
275
|
await opts.hooks.runToolAfter({
|
|
250
276
|
tool: tool.name,
|
|
251
277
|
sessionId: opts.sessionId,
|
|
@@ -280,6 +306,10 @@ export function wrapSystemAgentTool<TParams extends TSchema, TDetails = unknown>
|
|
|
280
306
|
if (blockResult !== undefined) {
|
|
281
307
|
throw new Error(`blocked: ${blockResult.reason}`)
|
|
282
308
|
}
|
|
309
|
+
const loopDecision = sharedLoopGuard.check(opts.sessionId, tool.name, mutableArgs)
|
|
310
|
+
if (loopDecision.kind === 'block') {
|
|
311
|
+
throw new Error(loopDecision.message)
|
|
312
|
+
}
|
|
283
313
|
const guardResult = await runFinalWriteGuards({
|
|
284
314
|
tool: tool.name,
|
|
285
315
|
args: mutableArgs,
|
|
@@ -299,6 +329,11 @@ export function wrapSystemAgentTool<TParams extends TSchema, TDetails = unknown>
|
|
|
299
329
|
content: result.content as ContentPart[],
|
|
300
330
|
details: result.details,
|
|
301
331
|
}
|
|
332
|
+
if (loopDecision.kind === 'warn') {
|
|
333
|
+
const warned = appendLoopWarning(hookResult, loopDecision.message)
|
|
334
|
+
hookResult.content = warned.content
|
|
335
|
+
hookResult.details = warned.details
|
|
336
|
+
}
|
|
302
337
|
await opts.hooks.runToolAfter({
|
|
303
338
|
tool: tool.name,
|
|
304
339
|
sessionId: opts.sessionId,
|
|
@@ -340,6 +375,10 @@ export function wrapAgentToolAsCustomToolDefinition<TParams extends TSchema, TDe
|
|
|
340
375
|
if (blockResult !== undefined) {
|
|
341
376
|
throw new Error(`blocked: ${blockResult.reason}`)
|
|
342
377
|
}
|
|
378
|
+
const loopDecision = sharedLoopGuard.check(opts.sessionId, tool.name, mutableArgs)
|
|
379
|
+
if (loopDecision.kind === 'block') {
|
|
380
|
+
throw new Error(loopDecision.message)
|
|
381
|
+
}
|
|
343
382
|
const guardResult = await runFinalWriteGuards({
|
|
344
383
|
tool: tool.name,
|
|
345
384
|
args: mutableArgs,
|
|
@@ -359,6 +398,11 @@ export function wrapAgentToolAsCustomToolDefinition<TParams extends TSchema, TDe
|
|
|
359
398
|
content: result.content as ContentPart[],
|
|
360
399
|
details: result.details,
|
|
361
400
|
}
|
|
401
|
+
if (loopDecision.kind === 'warn') {
|
|
402
|
+
const warned = appendLoopWarning(hookResult, loopDecision.message)
|
|
403
|
+
hookResult.content = warned.content
|
|
404
|
+
hookResult.details = warned.details
|
|
405
|
+
}
|
|
362
406
|
await opts.hooks.runToolAfter({
|
|
363
407
|
tool: tool.name,
|
|
364
408
|
sessionId: opts.sessionId,
|
|
@@ -381,6 +425,19 @@ export function buildBuiltinPiToolOverrides(opts: WrapSystemToolOptions): ToolDe
|
|
|
381
425
|
return defaultBuiltinPiAgentTools().map((tool) => wrapAgentToolAsCustomToolDefinition(tool, opts))
|
|
382
426
|
}
|
|
383
427
|
|
|
428
|
+
function appendLoopWarning(result: ToolResult, message: string): ToolResult {
|
|
429
|
+
const content: ContentPart[] = [...(result.content as ContentPart[]), { type: 'text', text: message }]
|
|
430
|
+
return { content, details: result.details }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Test-only seam: swaps the shared loop guard for a fresh instance so tests
|
|
434
|
+
// that reuse sessionIds across cases don't see cross-test streak counts.
|
|
435
|
+
// Production code never calls this; the guard's LRU bound handles
|
|
436
|
+
// long-running processes.
|
|
437
|
+
export function __resetSharedLoopGuardForTests(): void {
|
|
438
|
+
sharedLoopGuard = createLoopGuard()
|
|
439
|
+
}
|
|
440
|
+
|
|
384
441
|
function errorResult(message: string) {
|
|
385
442
|
return {
|
|
386
443
|
content: [{ type: 'text' as const, text: message }],
|
package/src/agent/subagents.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { Stream, Unsubscribe } from '@/stream'
|
|
|
7
7
|
import { type AgentSession, createSession } from './index'
|
|
8
8
|
import { subscribeProviderErrors } from './provider-error'
|
|
9
9
|
import type { SessionOrigin } from './session-origin'
|
|
10
|
+
import { renderTurnTimeAnchor } from './system-prompt'
|
|
10
11
|
import type { ToolResultBudget } from './tool-result-budget'
|
|
11
12
|
|
|
12
13
|
type AgentSessionTools = NonNullable<Parameters<typeof createSession>[0]>['tools']
|
|
@@ -226,7 +227,7 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
|
|
|
226
227
|
await hooks.runSessionTurnStart({ ...turnEvent, userPrompt: userPromptForTurn })
|
|
227
228
|
}
|
|
228
229
|
try {
|
|
229
|
-
await session.prompt(userPromptForTurn)
|
|
230
|
+
await session.prompt(`${renderTurnTimeAnchor()}\n\n${userPromptForTurn}`)
|
|
230
231
|
} finally {
|
|
231
232
|
if (hooks && turnEvent !== undefined) {
|
|
232
233
|
await hooks.runSessionTurnEnd(turnEvent)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatLocalDateTime, resolveLocalTimezoneName } from '@/shared'
|
|
1
|
+
import { formatLocalDateTime, formatLocalWeekday, resolveLocalTimezoneName } from '@/shared'
|
|
2
2
|
|
|
3
3
|
export const DEFAULT_SYSTEM_PROMPT = `You are a general-purpose AI agent running inside TypeClaw.
|
|
4
4
|
|
|
@@ -12,7 +12,17 @@ TypeClaw is domain-agnostic — your purpose is defined by \`IDENTITY.md\`, your
|
|
|
12
12
|
- **AGENTS.md** *(read on demand)* — your operating manual. Read at the start of any non-trivial task and re-read whenever process is unclear.
|
|
13
13
|
- **\`memory/topics/\`** *(always injected below, READ-ONLY)* — sharded long-term memory, owned by the dreaming subagent. To capture something memorable, surface it in your reply or let the memory-logger append to \`memory/streams/\`; never edit memory shards directly.
|
|
14
14
|
|
|
15
|
-
If a task reveals durable guidance or identity/user context, update the owning file (IDENTITY / SOUL / USER / AGENTS) — never memory shards.
|
|
15
|
+
If a task reveals durable guidance or identity/user context, update the owning file (IDENTITY / SOUL / USER / AGENTS) — never memory shards. **Use this routing when you have something durable to record:**
|
|
16
|
+
|
|
17
|
+
- *role, function, scope of work, who you are to this user* → IDENTITY.md
|
|
18
|
+
- *voice, tone, register, language preferences, persona quirks* → SOUL.md
|
|
19
|
+
- *facts about the user (name, timezone, projects, preferences they hold across tasks)* → USER.md
|
|
20
|
+
- *working conventions, repeatable procedures, "always do X" rules, things future-you needs to read before acting* → AGENTS.md
|
|
21
|
+
- *one-off context for this conversation only* → don't write a file; it'll be captured in \`memory/streams/\` automatically
|
|
22
|
+
|
|
23
|
+
When in doubt between SOUL.md and AGENTS.md: if it describes *how you sound*, it's SOUL; if it describes *how you work*, it's AGENTS. Tone preferences ("be more terse") go to SOUL.md; process rules ("always run tests before committing") go to AGENTS.md.
|
|
24
|
+
|
|
25
|
+
**Edit discipline.** Prefer rewriting in place to growing files. SOUL.md should stay short — a paragraph or two; if it's drifting past a screen, you're using it as a scratchpad and the model that reads it will start ignoring the back half. IDENTITY.md is similar — a few lines of who you are, not a résumé. AGENTS.md is the one allowed to grow. Don't rewrite SOUL.md on the first piece of tone feedback in a session — wait until the user repeats a preference or asks you directly to update it; a single off-day request isn't a durable change.
|
|
16
26
|
|
|
17
27
|
## Your workspace
|
|
18
28
|
|
|
@@ -66,6 +76,8 @@ The bundled \`explorer\` subagent is the right tool for **local** reconnaissance
|
|
|
66
76
|
|
|
67
77
|
The bundled \`scout\` subagent is its external counterpart — web research only. Use it when you need information from public sources (docs, library references, vendor changelogs, news, anything not already in this agent's folder). Scout runs \`websearch\` and \`webfetch\` in a fresh context window so the search churn does not pollute yours; it returns a citation-backed answer with a confidence rating. Prefer scout over running \`websearch\`/\`webfetch\` yourself when the research is non-trivial (more than 1-2 queries) or when you want to save your context for the synthesis step.
|
|
68
78
|
|
|
79
|
+
The bundled \`reviewer\` subagent is for **deep read-only analysis** — code review, PR review, plan review, design review, docs review. It runs on the \`deep\` profile (falls back to \`default\` if \`models.deep\` is unconfigured) so it can spend tokens on careful reasoning. It has the read-only filesystem tools, \`bash\` (for \`gh pr diff\`, \`git log\`, \`git diff\`, \`gh api -X GET\`, etc.), and the web tools (for verifying claims against OWASP, RFCs, library docs). It returns a structured \`<review>\` block with findings (severity \`blocker\`/\`concern\`/\`nit\`/\`praise\`, evidence quotes, suggestions) and a verdict (\`approve\`/\`request-changes\`/\`comment\`). Reviewer does NOT post — when reviewing a PR for a channel that wants comments posted, YOU translate its findings into \`gh api\` review-comment payloads and post them yourself. Use reviewer instead of doing review work in your own session whenever the target is non-trivial: a single-file lookup or a one-paragraph sanity check stays with you; a real PR, a multi-page design doc, a non-trivial plan — delegate.
|
|
80
|
+
|
|
69
81
|
**Mode B — Delegate-and-converse** (the user asked you to DO something long-running)
|
|
70
82
|
|
|
71
83
|
When the user hands you a task that will take minutes (a multi-step browser session, a long build, a complex external operation), acknowledge in plain language ("Alright, running that in the background — I'll let you know when it's done"), spawn one subagent with \`run_in_background: true\`, then KEEP TALKING. Stay available for follow-ups, related questions, parallel small tasks. When the completion reminder lands, weave the result into your next reply naturally. If the conversation has gone idle, proactively message the user with the result rather than waiting.
|
|
@@ -123,34 +135,35 @@ export function renderRuntimeBlock(version: string): string {
|
|
|
123
135
|
TypeClaw runtime version: ${version}.`
|
|
124
136
|
}
|
|
125
137
|
|
|
126
|
-
// Wall-clock anchor
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
138
|
+
// Wall-clock anchor injected into the **user turn**, not the system prompt.
|
|
139
|
+
//
|
|
140
|
+
// Why per-turn instead of session-creation: long-lived channel sessions can
|
|
141
|
+
// outlive a session-creation timestamp by days (a session opened Friday and
|
|
142
|
+
// woken Thursday morning happily reports "today is Friday" because the only
|
|
143
|
+
// dated reference in its context is the stale stamp). The per-turn anchor
|
|
144
|
+
// always reflects the moment the turn is about to be sent, so the model
|
|
145
|
+
// answers "what day is it" against `new Date()` rather than against the
|
|
146
|
+
// session-creation snapshot.
|
|
133
147
|
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
// to the
|
|
139
|
-
// cache prefix across session resurrections, and only the trailing ~60
|
|
140
|
-
// bytes invalidate.
|
|
148
|
+
// Why this still respects the prompt cache: the user turn is the only
|
|
149
|
+
// non-cacheable suffix in every provider's KV cache shape. Putting the
|
|
150
|
+
// anchor here invalidates exactly zero cached bytes — the same bytes that
|
|
151
|
+
// would already be re-billed on each turn's user message — so this is
|
|
152
|
+
// cache-free relative to the previous "## Now" placement.
|
|
141
153
|
//
|
|
142
|
-
// The
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
154
|
+
// The block emits both English and Korean weekday names alongside the ISO
|
|
155
|
+
// timestamp because models replying in a non-English language frequently
|
|
156
|
+
// compute weekday-from-ISO incorrectly; pre-computing the weekday in both
|
|
157
|
+
// candidate reply languages removes that arithmetic step entirely. The
|
|
158
|
+
// framing is a single `<current-time>` XML tag for parity with other
|
|
159
|
+
// runtime-injected per-turn blocks the agent already sees
|
|
160
|
+
// (`<system-reminder>` etc.), so the model reads it as a structured anchor
|
|
161
|
+
// rather than as content authored by a human in the chat.
|
|
162
|
+
export function renderTurnTimeAnchor(now: Date = new Date()): string {
|
|
149
163
|
const iso = formatLocalDateTime(now)
|
|
150
164
|
const zone = resolveLocalTimezoneName()
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
Session started at \`${iso}\` (${zone}). This is a session-creation snapshot, not a live clock — the value above does not advance during this session. If you need the current wall-clock time precisely (e.g. before scheduling a cron, replying with "it's 3pm", or computing a deadline), run \`date\` via bash instead of trusting this stamp; the container's timezone is set to the host's, so \`date\` returns the user's local time.`
|
|
165
|
+
const weekday = formatLocalWeekday(now)
|
|
166
|
+
return `<current-time>${iso} (${zone}, ${weekday.en} / ${weekday.ko})</current-time>`
|
|
154
167
|
}
|
|
155
168
|
|
|
156
169
|
// Compact replacement for DEFAULT_SYSTEM_PROMPT, used by non-interactive
|
|
@@ -8,9 +8,13 @@ import type { ChannelRouter } from '@/channels/router'
|
|
|
8
8
|
import type { AdapterId } from '@/channels/schema'
|
|
9
9
|
|
|
10
10
|
import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
|
|
11
|
+
import { normalizeRef } from './normalize-ref'
|
|
11
12
|
|
|
12
13
|
export type ChannelFetchAttachmentOrigin = {
|
|
13
14
|
adapter: AdapterId
|
|
15
|
+
workspace: string
|
|
16
|
+
chat: string
|
|
17
|
+
thread: string | null
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
export type CreateChannelFetchAttachmentToolOptions = {
|
|
@@ -34,18 +38,16 @@ export function createChannelFetchAttachmentTool({
|
|
|
34
38
|
name: 'channel_fetch_attachment',
|
|
35
39
|
label: 'Channel Fetch Attachment',
|
|
36
40
|
description:
|
|
37
|
-
'Download a file the user attached to the inbound channel message and save it to disk. Inbound channel ' +
|
|
38
|
-
'messages with
|
|
39
|
-
|
|
40
|
-
'the
|
|
41
|
-
'
|
|
42
|
-
'success returns the absolute path of the saved file plus its detected mimetype and size. On failure returns ' +
|
|
43
|
-
'the upstream error verbatim.',
|
|
41
|
+
'Download a file the user attached to the current inbound channel message and save it to disk. Inbound channel ' +
|
|
42
|
+
'messages with attachments show `[<Platform> attachment #N: <kind> <metadata>]` in the text. Pass `N` as ' +
|
|
43
|
+
'`attachment_id`; do not invent ids that are not present in the inbound message. The router validates the id ' +
|
|
44
|
+
'against the current turn and resolves the private platform ref itself. On success returns the absolute path ' +
|
|
45
|
+
'of the saved file plus its detected mimetype and size.',
|
|
44
46
|
parameters: Type.Object({
|
|
45
|
-
|
|
47
|
+
attachment_id: Type.Integer({
|
|
46
48
|
description:
|
|
47
|
-
'
|
|
48
|
-
|
|
49
|
+
'The number N from the inbound `[<Platform> attachment #N: ...]` placeholder. Must be present in this turn.',
|
|
50
|
+
minimum: 1,
|
|
49
51
|
}),
|
|
50
52
|
filename: Type.Optional(
|
|
51
53
|
Type.String({
|
|
@@ -58,10 +60,38 @@ export function createChannelFetchAttachmentTool({
|
|
|
58
60
|
|
|
59
61
|
async execute(_toolCallId, params) {
|
|
60
62
|
type Details = { ok: boolean; error?: string; path?: string; mimetype?: string; size?: number }
|
|
61
|
-
const
|
|
63
|
+
const found = router.lookupInboundAttachment({
|
|
64
|
+
adapter,
|
|
65
|
+
workspace: origin.workspace,
|
|
66
|
+
chat: origin.chat,
|
|
67
|
+
thread: origin.thread,
|
|
68
|
+
id: params.attachment_id,
|
|
69
|
+
})
|
|
70
|
+
if (found === null) {
|
|
71
|
+
const validIds = router.listInboundAttachmentIds({
|
|
72
|
+
adapter,
|
|
73
|
+
workspace: origin.workspace,
|
|
74
|
+
chat: origin.chat,
|
|
75
|
+
thread: origin.thread,
|
|
76
|
+
})
|
|
77
|
+
const validMsg =
|
|
78
|
+
validIds.length === 0
|
|
79
|
+
? 'no attachments are present in the current turn'
|
|
80
|
+
: `valid attachment_ids in this turn: ${validIds.join(', ')}`
|
|
81
|
+
return errorResult(
|
|
82
|
+
`no attachment with id=${params.attachment_id} in this turn (${validMsg}). Do not call channel_fetch_attachment for attachments that do not appear in the inbound message — they do not exist.`,
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
if (found.ref === '') {
|
|
86
|
+
return errorResult(
|
|
87
|
+
`attachment #${params.attachment_id} (${found.kind}) has no fetchable ref — likely a sticker or an upstream payload without a public URL. Acknowledge the user but do not promise to view it.`,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
const ref = normalizeRef(found.ref)
|
|
91
|
+
const filename = params.filename ?? found.filename
|
|
62
92
|
const result = await router.fetchAttachment(adapter, {
|
|
63
93
|
ref,
|
|
64
|
-
...(
|
|
94
|
+
...(filename !== undefined ? { filename } : {}),
|
|
65
95
|
})
|
|
66
96
|
if (!result.ok) {
|
|
67
97
|
logger.warn(formatChannelToolFailure('channel_fetch_attachment', `${adapter}: ${result.error}`))
|
|
@@ -98,10 +128,9 @@ export function createChannelFetchAttachmentTool({
|
|
|
98
128
|
})
|
|
99
129
|
}
|
|
100
130
|
|
|
101
|
-
function
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
return trimmed
|
|
131
|
+
function errorResult(message: string) {
|
|
132
|
+
const details = { ok: false, error: message }
|
|
133
|
+
return { content: [{ type: 'text' as const, text: `channel_fetch_attachment error: ${message}` }], details }
|
|
105
134
|
}
|
|
106
135
|
|
|
107
136
|
const UNSAFE_FILENAME_CHARS = /[^A-Za-z0-9._-]/g
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function normalizeRef(ref: string): string {
|
|
2
|
+
const trimmed = ref.trim()
|
|
3
|
+
// New classifiers store bare Slack file ids; legacy persisted refs (and
|
|
4
|
+
// anything still hitting the lookup path from older contextBuffer state)
|
|
5
|
+
// may carry the old prompt-visible `id=Fxxxx` prefix. Strip it here so
|
|
6
|
+
// both attachment-fetching tools route the same ref through the adapter
|
|
7
|
+
// callback — without this, `channel_fetch_attachment` would silently
|
|
8
|
+
// succeed on a legacy ref while `look_at_channel_attachment` would fail.
|
|
9
|
+
if (trimmed.startsWith('id=')) return trimmed.slice(3)
|
|
10
|
+
return trimmed
|
|
11
|
+
}
|
|
@@ -39,10 +39,13 @@ export type SkipResponseDetails = {
|
|
|
39
39
|
// `skip_response` is preferred whenever the model has a reason worth
|
|
40
40
|
// recording. See session-origin.ts for the prompt-level decision rule.
|
|
41
41
|
//
|
|
42
|
-
// Order-dependence with `channel_reply`/`channel_send
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
42
|
+
// Order-dependence with `channel_reply`/`channel_send` is asymmetric:
|
|
43
|
+
// - skip BEFORE any send → commits to silence; the router rejects any
|
|
44
|
+
// subsequent tool-source send this turn with `SKIP_RESPONSE_LOCK_ERROR`.
|
|
45
|
+
// - skip AFTER a send → accepted as a terminal no-op (`recorded-after-send`).
|
|
46
|
+
// The earlier reply stands; this posts nothing and ends the turn. Rejecting
|
|
47
|
+
// it (the old behavior) drove a livelock: denied a clean silent exit, the
|
|
48
|
+
// model re-sent, got re-denied on the next skip, and repeated to the cap.
|
|
46
49
|
export function createSkipResponseTool({
|
|
47
50
|
router,
|
|
48
51
|
sessionId,
|
|
@@ -55,12 +58,14 @@ export function createSkipResponseTool({
|
|
|
55
58
|
'Decline to send a user-facing reply this turn, with a logged reason. Use this ' +
|
|
56
59
|
'instead of narrating "I have nothing to add" / "I will stay quiet" in your visible ' +
|
|
57
60
|
'response. The reason is written to host logs (visible via `typeclaw logs -f`) but ' +
|
|
58
|
-
'never delivered to the user.
|
|
59
|
-
'`channel_reply` / `channel_send` in the same
|
|
60
|
-
'
|
|
61
|
-
'
|
|
62
|
-
'
|
|
63
|
-
'this
|
|
61
|
+
'never delivered to the user. If you call this BEFORE sending anything this turn, it ' +
|
|
62
|
+
'commits you to silence and any later `channel_reply` / `channel_send` in the same ' +
|
|
63
|
+
'turn is rejected. If you call it AFTER a reply has already landed this turn (e.g. you ' +
|
|
64
|
+
'posted an ack and now want to wait quietly for a backgrounded subagent), it is ' +
|
|
65
|
+
'accepted as a terminal no-op: your earlier reply stands, nothing further is sent, and ' +
|
|
66
|
+
'your turn ends. Either way, call this as your terminal tool when you decide to stop ' +
|
|
67
|
+
'talking — do NOT keep sending "still working" updates. Prefer this over the ' +
|
|
68
|
+
'`NO_REPLY` text sentinel whenever you have a reason worth recording.',
|
|
64
69
|
parameters: Type.Object({
|
|
65
70
|
reason: Type.String({
|
|
66
71
|
description:
|
|
@@ -85,33 +90,20 @@ export function createSkipResponseTool({
|
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
const result = router.markTurnSkipped({ parentSessionId: sessionId, reason })
|
|
88
|
-
if (result.kind === '
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
// Surface a clear error and refuse to stamp the flag so the rest of
|
|
94
|
-
// the turn behaves as a normal reply turn.
|
|
95
|
-
logger.warn(
|
|
96
|
-
formatChannelToolFailure(
|
|
97
|
-
'skip_response',
|
|
98
|
-
`channel send already happened this turn (reason=${JSON.stringify(reason)})`,
|
|
99
|
-
),
|
|
100
|
-
)
|
|
101
|
-
const details: SkipResponseDetails = {
|
|
102
|
-
ok: false,
|
|
103
|
-
suppressed: false,
|
|
104
|
-
reason,
|
|
105
|
-
error: 'send-already-happened',
|
|
106
|
-
}
|
|
93
|
+
if (result.kind === 'recorded-after-send') {
|
|
94
|
+
// Reply-first skip: an ack already landed; this just ends the turn
|
|
95
|
+
// quietly. Not suppressed (the reply stands) and not an error (erroring
|
|
96
|
+
// here is what drove the historical re-send livelock). Router logged it.
|
|
97
|
+
const details: SkipResponseDetails = { ok: true, suppressed: false, reason }
|
|
107
98
|
return {
|
|
108
99
|
content: [
|
|
109
100
|
{
|
|
110
101
|
type: 'text' as const,
|
|
111
102
|
text:
|
|
112
|
-
'skip_response
|
|
113
|
-
'
|
|
114
|
-
'
|
|
103
|
+
'skip_response accepted: your earlier channel reply this turn stands, and ' +
|
|
104
|
+
'no further message will be sent. End your turn now — do not send "still ' +
|
|
105
|
+
'working" updates while a backgrounded subagent runs; the completion ' +
|
|
106
|
+
'reminder will wake you when it finishes.',
|
|
115
107
|
},
|
|
116
108
|
],
|
|
117
109
|
details,
|
|
@@ -103,6 +103,7 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
|
|
|
103
103
|
if (params.description !== undefined) payload.description = params.description
|
|
104
104
|
|
|
105
105
|
const startedAt = now()
|
|
106
|
+
const spawnedByRole = permissions?.resolveRole(origin)
|
|
106
107
|
const { handle, completion } = startSubagent(subagentName, {
|
|
107
108
|
registry,
|
|
108
109
|
createSessionForSubagent,
|
|
@@ -110,6 +111,7 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
|
|
|
110
111
|
userPrompt: params.prompt,
|
|
111
112
|
payload: subagent.payloadSchema ? payload : undefined,
|
|
112
113
|
parentSessionId,
|
|
114
|
+
...(spawnedByRole !== undefined ? { spawnedByRole } : {}),
|
|
113
115
|
...(origin !== undefined ? { spawnedByOrigin: origin } : {}),
|
|
114
116
|
taskId,
|
|
115
117
|
})
|