typeclaw 0.26.0 → 0.27.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/src/agent/session-origin.ts +9 -1
- package/src/bundled-plugins/reviewer/skills/code-review.ts +3 -1
- package/src/channels/adapters/github/inbound.ts +52 -9
- package/src/channels/adapters/github/review-thread-resolver.ts +28 -3
- package/src/channels/router.ts +189 -7
- package/src/cli/inspect.ts +216 -36
- package/src/cli/logs.ts +15 -0
- package/src/cli/tui.ts +33 -39
- package/src/compose/logs.ts +1 -1
- package/src/container/logs.ts +70 -22
- package/src/inspect/index.ts +128 -42
- package/src/inspect/item-list.ts +44 -0
- package/src/inspect/item.ts +17 -0
- package/src/inspect/label.ts +1 -1
- package/src/inspect/logs-item.ts +79 -0
- package/src/inspect/loop.ts +74 -3
- package/src/inspect/open-item.ts +100 -0
- package/src/inspect/preview.ts +106 -0
- package/src/inspect/session-list.ts +15 -3
- package/src/inspect/transcript-view.ts +182 -0
- package/src/inspect/tui-item.ts +97 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +3 -1
- package/src/tui/index.ts +72 -32
package/package.json
CHANGED
|
@@ -297,6 +297,7 @@ function renderChannelOrigin(
|
|
|
297
297
|
chat: string
|
|
298
298
|
chatName?: string
|
|
299
299
|
thread: string | null
|
|
300
|
+
reactionRef?: ReactionRef
|
|
300
301
|
participants?: readonly ChannelParticipant[]
|
|
301
302
|
membership?: MembershipCount
|
|
302
303
|
self?: ChannelSelfIdentity
|
|
@@ -354,7 +355,14 @@ function renderChannelOrigin(
|
|
|
354
355
|
const conversationLine = renderConversationLine(origin)
|
|
355
356
|
if (conversationLine !== null) lines.push('', conversationLine)
|
|
356
357
|
|
|
357
|
-
|
|
358
|
+
// Gate on `reactionRef`, not just the static `supportsReactions` platform
|
|
359
|
+
// fact: a turn only has a message to react to when the triggering inbound
|
|
360
|
+
// carried one. Reminder-only turns (restart-resume, subagent-completion,
|
|
361
|
+
// idle/todo continuation) wake the session with no inbound, so
|
|
362
|
+
// `buildLiveOrigin` omits `reactionRef`. Prompting "react like a teammate"
|
|
363
|
+
// there made the model call `channel_react`, which then denied with "this
|
|
364
|
+
// conversation has no message to react to".
|
|
365
|
+
if (platformInfo.supportsReactions && origin.reactionRef !== undefined) {
|
|
358
366
|
lines.push(
|
|
359
367
|
'',
|
|
360
368
|
'**React like a teammate would.** You can drop an emoji on the message that',
|
|
@@ -66,7 +66,9 @@ Prioritize in this order:
|
|
|
66
66
|
|
|
67
67
|
### Re-reviews must re-decide, not observe
|
|
68
68
|
|
|
69
|
-
When the payload tells you this is a **re-review** — you (or this agent) previously requested changes on this PR and the author has pushed fixes
|
|
69
|
+
When the payload tells you this is a **re-review** — you (or this agent) previously requested changes on this PR and the author has pushed fixes — your verdict's whole purpose is to **re-decide the blocking state**, so:
|
|
70
|
+
|
|
71
|
+
This includes payloads where the parent says the author **addressed your prior blocking feedback** — "fixed both issues", "addressed your review", "pushed a fix" — even when the inbound was phrased conversationally rather than as an explicit "review again". An author responding to the blocker you raised IS the re-review trigger; the absence of the words "review again" does not downgrade it to a \`comment\`. Re-decide:
|
|
70
72
|
|
|
71
73
|
- Return **approve** if the blockers that drove the prior \`request-changes\` are resolved (leftover nits do not block — \`approve\` with inline nits is correct).
|
|
72
74
|
- Return **request-changes** if any blocker remains or a new one appeared.
|
|
@@ -187,6 +187,7 @@ export function classifyGithubInbound(
|
|
|
187
187
|
): InboundMessage | null {
|
|
188
188
|
const repository = readRepository(payload)
|
|
189
189
|
if (repository === null) return null
|
|
190
|
+
const mention = resolveBotMentionLogins(selfLogin, options?.authType ?? 'pat')
|
|
190
191
|
const base = {
|
|
191
192
|
adapter: 'github' as const,
|
|
192
193
|
workspace: `${repository.owner}/${repository.name}`,
|
|
@@ -209,7 +210,7 @@ export function classifyGithubInbound(
|
|
|
209
210
|
comment.body,
|
|
210
211
|
id,
|
|
211
212
|
user,
|
|
212
|
-
|
|
213
|
+
mention,
|
|
213
214
|
comment.created_at,
|
|
214
215
|
{ kind: 'issue-comment', owner: repository.owner, repo: repository.name, commentId: id },
|
|
215
216
|
)
|
|
@@ -228,7 +229,7 @@ export function classifyGithubInbound(
|
|
|
228
229
|
comment.body,
|
|
229
230
|
id,
|
|
230
231
|
readUser(comment.user),
|
|
231
|
-
|
|
232
|
+
mention,
|
|
232
233
|
comment.created_at,
|
|
233
234
|
{ kind: 'pr-review-comment', owner: repository.owner, repo: repository.name, commentId: id },
|
|
234
235
|
)
|
|
@@ -246,7 +247,7 @@ export function classifyGithubInbound(
|
|
|
246
247
|
comment.body,
|
|
247
248
|
id,
|
|
248
249
|
readUser(comment.user),
|
|
249
|
-
|
|
250
|
+
mention,
|
|
250
251
|
comment.created_at,
|
|
251
252
|
null,
|
|
252
253
|
)
|
|
@@ -270,7 +271,7 @@ export function classifyGithubInbound(
|
|
|
270
271
|
text,
|
|
271
272
|
id,
|
|
272
273
|
opener,
|
|
273
|
-
|
|
274
|
+
mention,
|
|
274
275
|
issue.created_at,
|
|
275
276
|
{ kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
|
|
276
277
|
action === 'opened' && !hasBody,
|
|
@@ -325,7 +326,7 @@ export function classifyGithubInbound(
|
|
|
325
326
|
prText,
|
|
326
327
|
id,
|
|
327
328
|
opener,
|
|
328
|
-
|
|
329
|
+
mention,
|
|
329
330
|
pr.created_at,
|
|
330
331
|
{ kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
|
|
331
332
|
isOpenLike && !hasBody,
|
|
@@ -352,7 +353,7 @@ export function classifyGithubInbound(
|
|
|
352
353
|
text,
|
|
353
354
|
id,
|
|
354
355
|
reviewer,
|
|
355
|
-
|
|
356
|
+
mention,
|
|
356
357
|
review.submitted_at,
|
|
357
358
|
null,
|
|
358
359
|
!hasBody,
|
|
@@ -377,7 +378,7 @@ export function classifyGithubInbound(
|
|
|
377
378
|
text,
|
|
378
379
|
id,
|
|
379
380
|
opener,
|
|
380
|
-
|
|
381
|
+
mention,
|
|
381
382
|
discussion.created_at,
|
|
382
383
|
null,
|
|
383
384
|
action === 'created' && !hasBody,
|
|
@@ -417,6 +418,48 @@ function resolveDecoyReviewerLogin(selfLogin: string, authType: 'pat' | 'app'):
|
|
|
417
418
|
return slug !== '' ? slug : null
|
|
418
419
|
}
|
|
419
420
|
|
|
421
|
+
// The @-handles that count as "addressed to us" in inbound body text. Under
|
|
422
|
+
// App auth `selfLogin` is the actor login `slug[bot]`, but GitHub renders a
|
|
423
|
+
// human's mention of the App as `@slug` (the bare slug — the decoy account's
|
|
424
|
+
// login), with no `[bot]` suffix and no way to type one. Matching only against
|
|
425
|
+
// `selfLogin` therefore never sees `@typeey` for a `typeey[bot]` actor, so a
|
|
426
|
+
// direct "@typeey review again" lands with isBotMention=false and falls through
|
|
427
|
+
// the engagement mention gate. Include the decoy slug so the bare-slug mention
|
|
428
|
+
// is recognized. Under PAT auth the bot IS a real user, so there is no decoy
|
|
429
|
+
// and only `selfLogin` applies.
|
|
430
|
+
export type BotMentionLogins = readonly string[]
|
|
431
|
+
|
|
432
|
+
export function resolveBotMentionLogins(selfLogin: string | null, authType: 'pat' | 'app'): BotMentionLogins {
|
|
433
|
+
if (selfLogin === null) return []
|
|
434
|
+
const decoyLogin = resolveDecoyReviewerLogin(selfLogin, authType)
|
|
435
|
+
return decoyLogin !== null ? [selfLogin, decoyLogin] : [selfLogin]
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// GitHub login chars are ASCII letters, digits, and hyphen. A `@login` token is
|
|
439
|
+
// a real mention of `login` only when the char right after it is not one of
|
|
440
|
+
// these — otherwise `@${login}` is a prefix of a longer, different login. This
|
|
441
|
+
// matters for the App decoy slug: `resolveBotMentionLogins('typeclaw[bot]')`
|
|
442
|
+
// yields the bare slug `typeclaw`, and a naive substring check would treat
|
|
443
|
+
// `@typeclaw-bot` (a different user) as a self-mention. The trailing `[` of
|
|
444
|
+
// `@typeclaw[bot]` is not a login char, so the full actor handle still matches.
|
|
445
|
+
const LOGIN_CHAR = /[A-Za-z0-9-]/
|
|
446
|
+
|
|
447
|
+
function textMentionsBot(text: string, mentionLogins: BotMentionLogins): boolean {
|
|
448
|
+
return mentionLogins.some((login) => mentionsLogin(text, login))
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function mentionsLogin(text: string, login: string): boolean {
|
|
452
|
+
const token = `@${login}`
|
|
453
|
+
let from = 0
|
|
454
|
+
for (;;) {
|
|
455
|
+
const at = text.indexOf(token, from)
|
|
456
|
+
if (at === -1) return false
|
|
457
|
+
const next = text[at + token.length]
|
|
458
|
+
if (next === undefined || !LOGIN_CHAR.test(next)) return true
|
|
459
|
+
from = at + 1
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
420
463
|
function classifyReviewRequest(input: ReviewRequestInput): InboundMessage | null {
|
|
421
464
|
const { action, payload, pr, number, base, selfLogin, authType, teamIsBotMember } = input
|
|
422
465
|
if (selfLogin === null) return null
|
|
@@ -550,7 +593,7 @@ function buildInbound(
|
|
|
550
593
|
rawText: unknown,
|
|
551
594
|
id: number,
|
|
552
595
|
user: GithubUser | null,
|
|
553
|
-
|
|
596
|
+
mention: BotMentionLogins,
|
|
554
597
|
rawTs: unknown,
|
|
555
598
|
reactionTarget: GithubReactionTarget | null,
|
|
556
599
|
synthesizedAwareness = false,
|
|
@@ -567,7 +610,7 @@ function buildInbound(
|
|
|
567
610
|
// Synthesized awareness lines carry an `@author` prefix describing who acted;
|
|
568
611
|
// that handle is the author, never a third-party mention of the bot, so the
|
|
569
612
|
// body-text mention heuristic must not fire on it.
|
|
570
|
-
const isBotMention = !synthesizedAwareness &&
|
|
613
|
+
const isBotMention = !synthesizedAwareness && textMentionsBot(text, mention)
|
|
571
614
|
return {
|
|
572
615
|
...key,
|
|
573
616
|
text,
|
|
@@ -9,7 +9,7 @@ const GRAPHQL_ENDPOINT = `${GITHUB_API_BASE}/graphql`
|
|
|
9
9
|
// carry more, so the resolver paginates until it matches the root comment id
|
|
10
10
|
// or exhausts the pages — stopping early on a 404-equivalent (thread absent)
|
|
11
11
|
// rather than fabricating a node id.
|
|
12
|
-
const THREADS_QUERY = `query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{id isResolved comments(first:1){nodes{databaseId author{login}}}}}}}}`
|
|
12
|
+
const THREADS_QUERY = `query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{id isResolved comments(first:1){nodes{databaseId author{__typename login}}}}}}}}`
|
|
13
13
|
|
|
14
14
|
const RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{id isResolved}}}`
|
|
15
15
|
|
|
@@ -18,6 +18,7 @@ type ReviewThreadNode = {
|
|
|
18
18
|
isResolved: boolean
|
|
19
19
|
rootCommentId: number | null
|
|
20
20
|
rootAuthorLogin: string | null
|
|
21
|
+
rootAuthorIsBot: boolean
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
type ThreadLookup =
|
|
@@ -66,7 +67,7 @@ export function createGithubReviewThreadResolver(deps: {
|
|
|
66
67
|
const thread = lookup.thread
|
|
67
68
|
// The load-bearing guard: only the bot may resolve the bot's own thread.
|
|
68
69
|
// Resolving a human reviewer's thread would erase their open question.
|
|
69
|
-
if (thread
|
|
70
|
+
if (!isSelfAuthor(thread, selfLogin)) {
|
|
70
71
|
return {
|
|
71
72
|
ok: false,
|
|
72
73
|
error: `refusing to resolve thread authored by @${thread.rootAuthorLogin ?? 'unknown'} (not @${selfLogin})`,
|
|
@@ -79,6 +80,29 @@ export function createGithubReviewThreadResolver(deps: {
|
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
// A GitHub App's own login differs across the two APIs this guard straddles:
|
|
84
|
+
// REST `getSelf` returns `slug[bot]` (selfLogin) but GraphQL's `Bot` author node
|
|
85
|
+
// returns the bare `slug` (rootAuthorLogin). Strict `===` thus refused the App's
|
|
86
|
+
// OWN thread (production: "refusing to resolve thread authored by @typeey (not
|
|
87
|
+
// @typeey[bot])"). The bare-slug match is gated on the GraphQL author actually
|
|
88
|
+
// being a `Bot`: a human `User` can legitimately own the bare slug as a login
|
|
89
|
+
// (e.g. the user `typeey` exists alongside the App `typeey[bot]`), so a User
|
|
90
|
+
// author must still match `selfLogin` exactly — otherwise the suffix-strip would
|
|
91
|
+
// let the bot close a human reviewer's thread, defeating the guard above.
|
|
92
|
+
const BOT_LOGIN_SUFFIX = '[bot]'
|
|
93
|
+
|
|
94
|
+
function isSelfAuthor(thread: ReviewThreadNode, selfLogin: string): boolean {
|
|
95
|
+
if (thread.rootAuthorLogin === null) return false
|
|
96
|
+
if (thread.rootAuthorIsBot) {
|
|
97
|
+
return normalizeBotLogin(thread.rootAuthorLogin) === normalizeBotLogin(selfLogin)
|
|
98
|
+
}
|
|
99
|
+
return thread.rootAuthorLogin === selfLogin
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizeBotLogin(login: string): string {
|
|
103
|
+
return login.endsWith(BOT_LOGIN_SUFFIX) ? login.slice(0, -BOT_LOGIN_SUFFIX.length) : login
|
|
104
|
+
}
|
|
105
|
+
|
|
82
106
|
type ResolveTarget = { owner: string; repo: string; prNumber: number; rootCommentId: number }
|
|
83
107
|
|
|
84
108
|
function parseTarget(req: ReviewThreadResolveRequest): ResolveTarget | null {
|
|
@@ -175,6 +199,7 @@ async function parseThreadsPage(response: Response): Promise<ThreadsPage> {
|
|
|
175
199
|
isResolved: n.isResolved,
|
|
176
200
|
rootCommentId: root?.databaseId ?? null,
|
|
177
201
|
rootAuthorLogin: root?.author?.login ?? null,
|
|
202
|
+
rootAuthorIsBot: root?.author?.__typename === 'Bot',
|
|
178
203
|
}
|
|
179
204
|
})
|
|
180
205
|
return { kind: 'ok', nodes, hasNextPage: connection.pageInfo.hasNextPage, endCursor: connection.pageInfo.endCursor }
|
|
@@ -231,7 +256,7 @@ type GraphqlThreadsResponse = {
|
|
|
231
256
|
nodes: Array<{
|
|
232
257
|
id: string
|
|
233
258
|
isResolved: boolean
|
|
234
|
-
comments: { nodes: Array<{ databaseId?: number; author?: { login?: string } }> }
|
|
259
|
+
comments: { nodes: Array<{ databaseId?: number; author?: { __typename?: string; login?: string } }> }
|
|
235
260
|
}>
|
|
236
261
|
}
|
|
237
262
|
}
|
package/src/channels/router.ts
CHANGED
|
@@ -2854,7 +2854,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2854
2854
|
return
|
|
2855
2855
|
}
|
|
2856
2856
|
|
|
2857
|
-
const { text:
|
|
2857
|
+
const { text: candidateText, source } = candidate
|
|
2858
|
+
let assistantText = candidateText
|
|
2858
2859
|
|
|
2859
2860
|
if (endsWithNoReplySignal(assistantText)) {
|
|
2860
2861
|
const leakedReasoning = !isNoReplySignal(assistantText)
|
|
@@ -2874,10 +2875,49 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2874
2875
|
return
|
|
2875
2876
|
}
|
|
2876
2877
|
|
|
2877
|
-
|
|
2878
|
-
|
|
2878
|
+
// Plain-text tool-call leak: the model serialized a channel tool call as
|
|
2879
|
+
// ordinary prose instead of producing a real tool call (a Kimi-on-Fireworks
|
|
2880
|
+
// failure mode — see `isLikelyPlainTextChannelToolCall`). We can't post the
|
|
2881
|
+
// raw `channel_reply({...})` serialization to the channel, but for
|
|
2882
|
+
// reply/send the model's *intent* is unambiguous: deliver the `text` arg.
|
|
2883
|
+
// Extract it and recover the actual message. `skip_response` is the
|
|
2884
|
+
// opposite — a genuine decline — so it stays suppressed.
|
|
2885
|
+
const plainTextToolCallKind = getPlainTextChannelToolCallKind(assistantText)
|
|
2886
|
+
if (plainTextToolCallKind === 'skip') {
|
|
2887
|
+
logger.warn(
|
|
2888
|
+
`[channels] ${live.keyId}: suppressed plain_text_channel_skip_response text_len=${assistantText.length}`,
|
|
2889
|
+
)
|
|
2879
2890
|
return
|
|
2880
2891
|
}
|
|
2892
|
+
if (plainTextToolCallKind !== null) {
|
|
2893
|
+
const extracted = extractPlainTextChannelToolCallText(assistantText)
|
|
2894
|
+
// Unextractable (no `text` arg, empty value, or fully-truncated): fall
|
|
2895
|
+
// back to the historical safe behavior — drop it rather than leak plumbing.
|
|
2896
|
+
if (extracted === null) {
|
|
2897
|
+
logger.warn(
|
|
2898
|
+
`[channels] ${live.keyId}: suppressed unextractable_plain_text_channel_tool_call text_len=${assistantText.length}`,
|
|
2899
|
+
)
|
|
2900
|
+
return
|
|
2901
|
+
}
|
|
2902
|
+
// The extracted value is still untrusted model output: if it is itself a
|
|
2903
|
+
// no-reply signal, an empty-response sentinel, or another (nested) leaked
|
|
2904
|
+
// tool call, suppress it through the same guards rather than re-leaking.
|
|
2905
|
+
if (
|
|
2906
|
+
endsWithNoReplySignal(extracted) ||
|
|
2907
|
+
isUpstreamEmptyResponseSentinel(extracted) ||
|
|
2908
|
+
isLikelyKimiChannelToolLeak(extracted) ||
|
|
2909
|
+
isLikelyPlainTextChannelToolCall(extracted)
|
|
2910
|
+
) {
|
|
2911
|
+
logger.warn(
|
|
2912
|
+
`[channels] ${live.keyId}: suppressed plain_text_channel_tool_call (unsafe extracted text) text_len=${extracted.length}`,
|
|
2913
|
+
)
|
|
2914
|
+
return
|
|
2915
|
+
}
|
|
2916
|
+
logger.warn(
|
|
2917
|
+
`[channels] ${live.keyId}: recovered plain_text_channel_tool_call kind=${plainTextToolCallKind} text_len=${extracted.length}`,
|
|
2918
|
+
)
|
|
2919
|
+
assistantText = extracted
|
|
2920
|
+
}
|
|
2881
2921
|
|
|
2882
2922
|
// `source` distinguishes the three recovery shapes for log triage:
|
|
2883
2923
|
// - 'leaf': the assistant message IS the leaf with stopReason 'stop'
|
|
@@ -4233,8 +4273,12 @@ export function isLikelyKimiChannelToolLeak(text: string): boolean {
|
|
|
4233
4273
|
//
|
|
4234
4274
|
// Structural-only detection (NOT a substring search): the trimmed text must
|
|
4235
4275
|
// *start* with `channel_reply(`, `channel_send(`, or `skip_response(`, and
|
|
4236
|
-
// that opening paren must enclose at least one `"` (the
|
|
4237
|
-
//
|
|
4276
|
+
// that opening paren must enclose at least one quote — `"` or `'` (the
|
|
4277
|
+
// serialized argument). The single-quote arm matters because the extractor
|
|
4278
|
+
// recovers single-quoted values too; if the classifier only matched `"`, a
|
|
4279
|
+
// single-quoted leak like `channel_reply({text: 'hi'})` would bypass the
|
|
4280
|
+
// extractor and post raw plumbing. This deliberately matches the leak shape
|
|
4281
|
+
// while letting prose that merely
|
|
4238
4282
|
// *mentions* a tool name (e.g. "I would normally call channel_reply here
|
|
4239
4283
|
// but...") reach the user — that false-positive class is already locked in by
|
|
4240
4284
|
// the `still recovers prose that mentions channel_reply` test.
|
|
@@ -4242,12 +4286,150 @@ export function isLikelyKimiChannelToolLeak(text: string): boolean {
|
|
|
4242
4286
|
// The trailing close paren is NOT required: the model sometimes truncates
|
|
4243
4287
|
// mid-serialization, and a half-leaked `channel_reply({"text":"..."` is
|
|
4244
4288
|
// just as user-hostile as the full shape.
|
|
4245
|
-
const PLAIN_TEXT_CHANNEL_TOOL_CALL_RE = /^(
|
|
4289
|
+
const PLAIN_TEXT_CHANNEL_TOOL_CALL_RE = /^(channel_reply|channel_send|skip_response)\s*\(\s*[^)]*["']/
|
|
4290
|
+
|
|
4291
|
+
export type PlainTextChannelToolCallKind = 'reply' | 'send' | 'skip'
|
|
4292
|
+
|
|
4293
|
+
export function getPlainTextChannelToolCallKind(text: string): PlainTextChannelToolCallKind | null {
|
|
4294
|
+
const match = PLAIN_TEXT_CHANNEL_TOOL_CALL_RE.exec(text.trim())
|
|
4295
|
+
if (match === null) return null
|
|
4296
|
+
switch (match[1]) {
|
|
4297
|
+
case 'channel_reply':
|
|
4298
|
+
return 'reply'
|
|
4299
|
+
case 'channel_send':
|
|
4300
|
+
return 'send'
|
|
4301
|
+
case 'skip_response':
|
|
4302
|
+
return 'skip'
|
|
4303
|
+
default:
|
|
4304
|
+
return null
|
|
4305
|
+
}
|
|
4306
|
+
}
|
|
4246
4307
|
|
|
4247
4308
|
export function isLikelyPlainTextChannelToolCall(text: string): boolean {
|
|
4248
|
-
return
|
|
4309
|
+
return getPlainTextChannelToolCallKind(text) !== null
|
|
4310
|
+
}
|
|
4311
|
+
|
|
4312
|
+
// Tolerant single-purpose scanner that pulls the `text` argument out of a
|
|
4313
|
+
// plain-text-serialized `channel_reply(...)` / `channel_send(...)` leak. A
|
|
4314
|
+
// single regex covering every shape (double/single/unquoted keys, escaped
|
|
4315
|
+
// quotes, mid-serialization truncation) is fragile, so this walks the string
|
|
4316
|
+
// once and extracts only the first string-valued `text` property. `channel_send`
|
|
4317
|
+
// also carries `adapter`/`chat`/`thread`, which are intentionally ignored —
|
|
4318
|
+
// recovery always routes back through the current channel, never a
|
|
4319
|
+
// model-supplied destination. Returns null when no recoverable, non-empty
|
|
4320
|
+
// `text` value is present so the caller can fall back to suppression.
|
|
4321
|
+
export function extractPlainTextChannelToolCallText(text: string): string | null {
|
|
4322
|
+
const trimmed = text.trim()
|
|
4323
|
+
if (!/^(?:channel_reply|channel_send)\s*\(/.test(trimmed)) return null
|
|
4324
|
+
|
|
4325
|
+
// Walk the serialization once, honoring a `text` key only at the top level of
|
|
4326
|
+
// the argument object (braceDepth 1, outside any array). Two failure classes
|
|
4327
|
+
// motivate the bookkeeping: a `text:` inside an earlier quoted value, e.g.
|
|
4328
|
+
// `channel_send({ reason: "see text: here", text: "real" })`, and a `text:`
|
|
4329
|
+
// inside a *nested* object, e.g. `channel_reply({ meta: { text: "x" }, text:
|
|
4330
|
+
// "real" })`. Skipping string literals defeats the first; tracking
|
|
4331
|
+
// brace/bracket depth and matching keys only at top level defeats the second.
|
|
4332
|
+
// Either way the scanner lands on the real reply instead of leaking the wrong
|
|
4333
|
+
// value or dropping the message.
|
|
4334
|
+
let braceDepth = 0
|
|
4335
|
+
let bracketDepth = 0
|
|
4336
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
4337
|
+
const ch = trimmed[i]!
|
|
4338
|
+
|
|
4339
|
+
if (ch === '"' || ch === "'") {
|
|
4340
|
+
i = skipStringLiteral(trimmed, i, ch)
|
|
4341
|
+
continue
|
|
4342
|
+
}
|
|
4343
|
+
|
|
4344
|
+
if (ch === '{') {
|
|
4345
|
+
braceDepth++
|
|
4346
|
+
if (braceDepth === 1 && bracketDepth === 0) {
|
|
4347
|
+
const value = readTextKeyValueAt(trimmed, i + 1)
|
|
4348
|
+
if (value !== undefined) return value
|
|
4349
|
+
}
|
|
4350
|
+
continue
|
|
4351
|
+
}
|
|
4352
|
+
if (ch === '}') {
|
|
4353
|
+
if (braceDepth > 0) braceDepth--
|
|
4354
|
+
continue
|
|
4355
|
+
}
|
|
4356
|
+
if (ch === '[') {
|
|
4357
|
+
bracketDepth++
|
|
4358
|
+
continue
|
|
4359
|
+
}
|
|
4360
|
+
if (ch === ']') {
|
|
4361
|
+
if (bracketDepth > 0) bracketDepth--
|
|
4362
|
+
continue
|
|
4363
|
+
}
|
|
4364
|
+
|
|
4365
|
+
if (ch === ',' && braceDepth === 1 && bracketDepth === 0) {
|
|
4366
|
+
const value = readTextKeyValueAt(trimmed, i + 1)
|
|
4367
|
+
if (value !== undefined) return value
|
|
4368
|
+
}
|
|
4369
|
+
}
|
|
4370
|
+
|
|
4371
|
+
return null
|
|
4372
|
+
}
|
|
4373
|
+
|
|
4374
|
+
// Returns the recovered value (string or null) when a `text` key starts at
|
|
4375
|
+
// `from`, or undefined when no `text` key is present there so the scanner keeps
|
|
4376
|
+
// walking. The null/undefined split lets a malformed `text` value short-circuit
|
|
4377
|
+
// to suppression while a non-`text` delimiter is simply skipped.
|
|
4378
|
+
function readTextKeyValueAt(s: string, from: number): string | null | undefined {
|
|
4379
|
+
const afterKey = matchTextKey(s, from)
|
|
4380
|
+
if (afterKey === null) return undefined
|
|
4381
|
+
|
|
4382
|
+
const quote = s[afterKey]
|
|
4383
|
+
if (quote !== '"' && quote !== "'") return null
|
|
4384
|
+
return readStringValue(s, afterKey + 1, quote)
|
|
4385
|
+
}
|
|
4386
|
+
|
|
4387
|
+
// Returns the closing-quote index, or the last index when the literal is
|
|
4388
|
+
// truncated, so the caller's `i++` resumes past the consumed string.
|
|
4389
|
+
function skipStringLiteral(s: string, openIdx: number, quote: string): number {
|
|
4390
|
+
let escaped = false
|
|
4391
|
+
for (let i = openIdx + 1; i < s.length; i++) {
|
|
4392
|
+
const ch = s[i]!
|
|
4393
|
+
if (escaped) {
|
|
4394
|
+
escaped = false
|
|
4395
|
+
continue
|
|
4396
|
+
}
|
|
4397
|
+
if (ch === '\\') {
|
|
4398
|
+
escaped = true
|
|
4399
|
+
continue
|
|
4400
|
+
}
|
|
4401
|
+
if (ch === quote) return i
|
|
4402
|
+
}
|
|
4403
|
+
return s.length
|
|
4404
|
+
}
|
|
4405
|
+
|
|
4406
|
+
function matchTextKey(s: string, from: number): number | null {
|
|
4407
|
+
const m = /^\s*(?:"text"|'text'|text)\s*:\s*/.exec(s.slice(from))
|
|
4408
|
+
return m === null ? null : from + m[0].length
|
|
4249
4409
|
}
|
|
4250
4410
|
|
|
4411
|
+
function readStringValue(s: string, from: number, quote: string): string | null {
|
|
4412
|
+
let value = ''
|
|
4413
|
+
let escaped = false
|
|
4414
|
+
for (let i = from; i < s.length; i++) {
|
|
4415
|
+
const ch = s[i]!
|
|
4416
|
+
if (escaped) {
|
|
4417
|
+
value += ESCAPE_REPLACEMENTS[ch] ?? ch
|
|
4418
|
+
escaped = false
|
|
4419
|
+
continue
|
|
4420
|
+
}
|
|
4421
|
+
if (ch === '\\') {
|
|
4422
|
+
escaped = true
|
|
4423
|
+
continue
|
|
4424
|
+
}
|
|
4425
|
+
if (ch === quote) break
|
|
4426
|
+
value += ch
|
|
4427
|
+
}
|
|
4428
|
+
return value.trim().length > 0 ? value : null
|
|
4429
|
+
}
|
|
4430
|
+
|
|
4431
|
+
const ESCAPE_REPLACEMENTS: Record<string, string> = { n: '\n', r: '\r', t: '\t' }
|
|
4432
|
+
|
|
4251
4433
|
function describe(err: unknown): string {
|
|
4252
4434
|
return err instanceof Error ? err.message : String(err)
|
|
4253
4435
|
}
|