typeclaw 0.28.2 → 0.30.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/index.ts +43 -5
- package/src/agent/live-subagents.ts +5 -0
- package/src/agent/loop-guard.ts +112 -26
- package/src/agent/plugin-tools.ts +167 -50
- package/src/agent/session-origin.ts +3 -3
- package/src/agent/subagent-drain.ts +150 -0
- package/src/agent/subagents.ts +41 -3
- package/src/agent/system-prompt.ts +29 -4
- package/src/agent/tools/channel-send.ts +1 -1
- package/src/agent/tools/spawn-subagent.ts +34 -1
- package/src/agent/tools/subagent-output.ts +7 -3
- package/src/agent/tools/wikipedia.ts +1 -1
- package/src/bundled-plugins/bun-hygiene/README.md +12 -11
- package/src/bundled-plugins/bun-hygiene/policy.ts +8 -3
- package/src/bundled-plugins/explorer/explorer.ts +2 -0
- package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +94 -0
- package/src/bundled-plugins/github-cli-auth/effective-approval.ts +98 -0
- package/src/bundled-plugins/github-cli-auth/gh-review-inline-detect.ts +130 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +27 -2
- package/src/bundled-plugins/github-cli-auth/review-recorder.ts +12 -4
- package/src/bundled-plugins/memory/memory-logger.ts +3 -3
- package/src/bundled-plugins/operator/operator.ts +2 -0
- package/src/bundled-plugins/planner/index.ts +11 -0
- package/src/bundled-plugins/planner/planner.ts +283 -0
- package/src/bundled-plugins/planner/skills/general.ts +65 -0
- package/src/bundled-plugins/planner/skills/project.ts +69 -0
- package/src/bundled-plugins/researcher/index.ts +11 -0
- package/src/bundled-plugins/researcher/researcher.ts +233 -0
- package/src/bundled-plugins/researcher/skills/general.ts +105 -0
- package/src/bundled-plugins/researcher/write-report.ts +107 -0
- package/src/bundled-plugins/reviewer/reviewer.ts +28 -9
- package/src/bundled-plugins/reviewer/skills/data-review.ts +77 -0
- package/src/bundled-plugins/reviewer/skills/doc-review.ts +79 -0
- package/src/bundled-plugins/reviewer/skills/plan-review.ts +64 -0
- package/src/bundled-plugins/reviewer/skills/security-audit.ts +70 -0
- package/src/bundled-plugins/reviewer/skills/writing-review.ts +63 -0
- package/src/bundled-plugins/scout/scout.ts +2 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +8 -4
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +3 -2
- package/src/channels/adapters/discord-bot.ts +38 -11
- package/src/channels/adapters/github/inbound.ts +68 -4
- package/src/channels/adapters/kakaotalk-classify.ts +2 -2
- package/src/channels/adapters/kakaotalk.ts +2 -2
- package/src/channels/adapters/slack-bot-classify.ts +1 -1
- package/src/channels/adapters/slack-bot.ts +3 -0
- package/src/channels/adapters/telegram-bot.ts +3 -0
- package/src/channels/engagement.ts +12 -7
- package/src/channels/github-review-claim.ts +15 -3
- package/src/channels/router.ts +85 -9
- package/src/channels/schema.ts +1 -1
- package/src/channels/types.ts +6 -0
- package/src/cli/init.ts +13 -2
- package/src/cli/ui.ts +64 -0
- package/src/config/config.ts +21 -15
- package/src/container/start.ts +5 -1
- package/src/init/dockerfile.ts +19 -56
- package/src/init/hatching.ts +1 -1
- package/src/init/index.ts +5 -1
- package/src/migrations/index.ts +35 -0
- package/src/migrations/secrets-v1-to-v2.ts +344 -0
- package/src/run/bundled-plugins.ts +4 -0
- package/src/run/index.ts +13 -0
- package/src/sandbox/availability.ts +12 -0
- package/src/sandbox/build.ts +12 -0
- package/src/sandbox/index.ts +1 -1
- package/src/sandbox/policy.ts +8 -0
- package/src/server/index.ts +24 -5
- package/src/shared/host-locale.ts +27 -0
- package/src/shared/protocol.ts +1 -1
- package/src/shared/wordmark.ts +19 -0
- package/src/skills/typeclaw-config/SKILL.md +32 -32
- package/src/skills/typeclaw-kaomoji/SKILL.md +3 -3
- package/src/skills/typeclaw-tunnels/SKILL.md +3 -1
- package/src/tui/banner.ts +19 -0
- package/src/tui/format.ts +34 -0
- package/src/tui/index.ts +121 -22
- package/src/tui/theme.ts +26 -1
- package/src/tunnels/providers/cloudflare-named.ts +15 -4
- package/src/tunnels/providers/cloudflare-quick.ts +15 -4
- package/src/tunnels/providers/cloudflared-binary.ts +11 -0
- package/typeclaw.schema.json +15 -7
package/package.json
CHANGED
package/src/agent/index.ts
CHANGED
|
@@ -53,7 +53,12 @@ import { loadSelf } from './self'
|
|
|
53
53
|
import { SESSION_META_CUSTOM_TYPE, sessionMetaPayload } from './session-meta'
|
|
54
54
|
import { renderSessionOrigin, type SessionOrigin, type SessionRoleContext } from './session-origin'
|
|
55
55
|
import type { CreateSessionForSubagent, SubagentRegistry } from './subagents'
|
|
56
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
buildDefaultSystemPrompt,
|
|
58
|
+
DEFAULT_SUBAGENT_ROSTER,
|
|
59
|
+
renderRuntimeBlock,
|
|
60
|
+
SLIM_SYSTEM_PROMPT,
|
|
61
|
+
} from './system-prompt'
|
|
57
62
|
import { attachToolNotFoundNudge } from './tool-not-found-nudge'
|
|
58
63
|
import {
|
|
59
64
|
createBudgetState,
|
|
@@ -69,7 +74,7 @@ import { createChannelSendTool } from './tools/channel-send'
|
|
|
69
74
|
import { createGrantRoleTool } from './tools/grant-role'
|
|
70
75
|
import { createRestartTool } from './tools/restart'
|
|
71
76
|
import { createSkipResponseTool } from './tools/skip-response'
|
|
72
|
-
import { createSpawnSubagentTool } from './tools/spawn-subagent'
|
|
77
|
+
import { createSpawnSubagentTool, renderPublicSubagentRoster } from './tools/spawn-subagent'
|
|
73
78
|
import { createStreamSnapshotTool } from './tools/stream-snapshot'
|
|
74
79
|
import { createSubagentCancelTool } from './tools/subagent-cancel'
|
|
75
80
|
import { createSubagentOutputTool } from './tools/subagent-output'
|
|
@@ -208,6 +213,7 @@ export type CreateSessionOptions = {
|
|
|
208
213
|
liveSubagentRegistry?: LiveSubagentRegistry
|
|
209
214
|
subagentRegistry?: SubagentRegistry
|
|
210
215
|
createSessionForSubagent?: CreateSessionForSubagent
|
|
216
|
+
allowBackgroundFromSubagent?: boolean
|
|
211
217
|
}
|
|
212
218
|
|
|
213
219
|
export type CreateSessionResult = {
|
|
@@ -256,6 +262,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
256
262
|
...(options.permissions ? { permissions: options.permissions } : {}),
|
|
257
263
|
...(options.runtimeVersion !== undefined ? { runtimeVersion: options.runtimeVersion } : {}),
|
|
258
264
|
...(options.mcpManager !== undefined ? { mcpManager: options.mcpManager } : {}),
|
|
265
|
+
...(options.subagentRegistry !== undefined ? { subagentRegistry: options.subagentRegistry } : {}),
|
|
259
266
|
})
|
|
260
267
|
|
|
261
268
|
const getOrigin: () => SessionOrigin | undefined =
|
|
@@ -351,6 +358,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
|
|
|
351
358
|
getOrigin,
|
|
352
359
|
permissions: options.permissions,
|
|
353
360
|
stream: options.stream,
|
|
361
|
+
allowBackgroundFromSubagent: options.allowBackgroundFromSubagent,
|
|
354
362
|
}),
|
|
355
363
|
]
|
|
356
364
|
: [
|
|
@@ -580,7 +588,7 @@ export function formatRestartNotice(restartedAt: string): string {
|
|
|
580
588
|
|
|
581
589
|
// Variant for the session that called the `restart` tool. The user explicitly
|
|
582
590
|
// asked this conversation to restart; staying silent after the reboot is the
|
|
583
|
-
// reported bug ("
|
|
591
|
+
// reported bug (e.g. "wait, you don't even know you restarted?"). This notice instructs the
|
|
584
592
|
// model to acknowledge restart completion in its very next reply — once — then
|
|
585
593
|
// stop mentioning it. Same SYSTEM MESSAGE framing as the sibling notice so
|
|
586
594
|
// persona-rich models don't reply to the framing itself.
|
|
@@ -720,6 +728,7 @@ export function buildSubagentOrchestrationTools(opts: {
|
|
|
720
728
|
getOrigin: () => SessionOrigin | undefined
|
|
721
729
|
permissions: PermissionService | undefined
|
|
722
730
|
stream: Stream | undefined
|
|
731
|
+
allowBackgroundFromSubagent?: boolean
|
|
723
732
|
}): ToolDefinition[] {
|
|
724
733
|
if (
|
|
725
734
|
opts.liveRegistry === undefined ||
|
|
@@ -739,6 +748,9 @@ export function buildSubagentOrchestrationTools(opts: {
|
|
|
739
748
|
getOrigin: opts.getOrigin,
|
|
740
749
|
...(opts.permissions ? { permissions: opts.permissions } : {}),
|
|
741
750
|
...(opts.stream ? { stream: opts.stream } : {}),
|
|
751
|
+
...(opts.allowBackgroundFromSubagent !== undefined
|
|
752
|
+
? { allowBackgroundFromSubagent: opts.allowBackgroundFromSubagent }
|
|
753
|
+
: {}),
|
|
742
754
|
}),
|
|
743
755
|
createSubagentOutputTool({
|
|
744
756
|
liveRegistry: opts.liveRegistry,
|
|
@@ -899,6 +911,12 @@ export type CreateResourceLoaderOptions = {
|
|
|
899
911
|
mcpManager?: McpManager
|
|
900
912
|
permissions?: PermissionService
|
|
901
913
|
runtimeVersion?: string
|
|
914
|
+
// Public subagents whose names + `rosterDescription`s render the full-mode
|
|
915
|
+
// "## Subagent orchestration" roster. When omitted (no-registry callers, the
|
|
916
|
+
// debug dumper), the prompt falls back to `DEFAULT_SUBAGENT_ROSTER`. Threaded
|
|
917
|
+
// from `createSessionWithDispose`, where the merged registry is already in
|
|
918
|
+
// scope.
|
|
919
|
+
subagentRegistry?: SubagentRegistry
|
|
902
920
|
// Explicit override for the prompt mode. When omitted, the mode is derived
|
|
903
921
|
// from `origin.kind`: cron + subagent → slim, tui + channel → full. Pass
|
|
904
922
|
// 'full' to force the heavy prompt even on an unattended origin (rarely
|
|
@@ -957,6 +975,11 @@ export type SystemPromptMode = 'full' | 'slim'
|
|
|
957
975
|
export type SystemPromptComposition = {
|
|
958
976
|
mode?: SystemPromptMode
|
|
959
977
|
self: string
|
|
978
|
+
// Pre-rendered full-mode orchestration roster (from `renderPublicSubagentRoster`).
|
|
979
|
+
// Kept as a ready string so this composer stays pure and registry-free; the
|
|
980
|
+
// registry-aware caller renders it. Ignored in slim mode (no roster section).
|
|
981
|
+
// Falls back to `DEFAULT_SUBAGENT_ROSTER` when omitted.
|
|
982
|
+
subagentRoster?: string
|
|
960
983
|
runtimeVersion?: string
|
|
961
984
|
origin?: SessionOrigin
|
|
962
985
|
roleContext?: SessionRoleContext
|
|
@@ -990,7 +1013,10 @@ export type SystemPromptComposition = {
|
|
|
990
1013
|
// suffix anyway — and removes the staleness failure mode where a session
|
|
991
1014
|
// opened Friday answered "today is Friday" on Thursday.
|
|
992
1015
|
export function composeSystemPrompt(parts: SystemPromptComposition): string {
|
|
993
|
-
const base =
|
|
1016
|
+
const base =
|
|
1017
|
+
parts.mode === 'slim'
|
|
1018
|
+
? SLIM_SYSTEM_PROMPT
|
|
1019
|
+
: buildDefaultSystemPrompt(parts.subagentRoster ?? DEFAULT_SUBAGENT_ROSTER)
|
|
994
1020
|
let prompt = `${base}\n\n${parts.self}`
|
|
995
1021
|
if (parts.runtimeVersion !== undefined) {
|
|
996
1022
|
prompt = `${prompt}\n\n${renderRuntimeBlock(parts.runtimeVersion)}`
|
|
@@ -1013,7 +1039,18 @@ export function composeSystemPrompt(parts: SystemPromptComposition): string {
|
|
|
1013
1039
|
export async function createResourceLoader(options: CreateResourceLoaderOptions = {}): Promise<DefaultResourceLoader> {
|
|
1014
1040
|
const agentDir = options.agentDir ?? process.cwd()
|
|
1015
1041
|
const mode: SystemPromptMode = options.mode ?? deriveSystemPromptMode(options.origin)
|
|
1016
|
-
|
|
1042
|
+
// Slim mode (cron/subagent) has no orchestration section, so it never reads
|
|
1043
|
+
// the roster. Skip rendering it there — `renderPublicSubagentRoster` throws on
|
|
1044
|
+
// a public subagent with a missing/blank `rosterDescription`, and a slim
|
|
1045
|
+
// session must not fail on a roster it will never show.
|
|
1046
|
+
const subagentRoster =
|
|
1047
|
+
mode === 'slim'
|
|
1048
|
+
? undefined
|
|
1049
|
+
: options.subagentRegistry !== undefined
|
|
1050
|
+
? renderPublicSubagentRoster(options.subagentRegistry)
|
|
1051
|
+
: DEFAULT_SUBAGENT_ROSTER
|
|
1052
|
+
const basePrompt =
|
|
1053
|
+
mode === 'slim' ? SLIM_SYSTEM_PROMPT : buildDefaultSystemPrompt(subagentRoster ?? DEFAULT_SUBAGENT_ROSTER)
|
|
1017
1054
|
|
|
1018
1055
|
// Kick off the three independent I/O paths concurrently. Sequential awaits
|
|
1019
1056
|
// here used to be the dominant cold-start cost amplifier: loadSelf is 2
|
|
@@ -1077,6 +1114,7 @@ export async function createResourceLoader(options: CreateResourceLoaderOptions
|
|
|
1077
1114
|
const systemPrompt = composeSystemPrompt({
|
|
1078
1115
|
mode,
|
|
1079
1116
|
self,
|
|
1117
|
+
subagentRoster,
|
|
1080
1118
|
...(options.runtimeVersion !== undefined ? { runtimeVersion: options.runtimeVersion } : {}),
|
|
1081
1119
|
...(options.origin !== undefined ? { origin: options.origin } : {}),
|
|
1082
1120
|
...(roleContext !== undefined ? { roleContext } : {}),
|
|
@@ -23,6 +23,11 @@ export type LiveSubagent = {
|
|
|
23
23
|
// subagent_output/subagent_cancel. Absent when no permission service was
|
|
24
24
|
// active at spawn, in which case the cap fails closed.
|
|
25
25
|
spawnedByRole?: string
|
|
26
|
+
// True when spawned with run_in_background. Only background spawns deliver
|
|
27
|
+
// their result out-of-band (via the subagent.completed broadcast and the
|
|
28
|
+
// parent's drain); synchronous spawns return their result inline as the tool
|
|
29
|
+
// result, so the drain MUST NOT re-prompt for them. See runSubagentDrain.
|
|
30
|
+
background?: boolean
|
|
26
31
|
startedAt: number
|
|
27
32
|
status: SubagentStatus
|
|
28
33
|
completion?: SubagentCompletion
|
package/src/agent/loop-guard.ts
CHANGED
|
@@ -52,9 +52,40 @@ const DEFAULT_PATH_TARGET = '.'
|
|
|
52
52
|
|
|
53
53
|
const MAX_SESSIONS = 256
|
|
54
54
|
|
|
55
|
+
// The one tool with result-sensitive loop semantics: a poll returning 'running'
|
|
56
|
+
// is a legitimate wait, so its block is deferred until status is known (see
|
|
57
|
+
// `noteResult` / `deferable`). Kept as a local literal rather than importing the
|
|
58
|
+
// tool module to keep this primitive dependency-free; it must match
|
|
59
|
+
// SUBAGENT_OUTPUT_TOOL_NAME in tools/subagent-output.ts.
|
|
60
|
+
const SUBAGENT_OUTPUT_TOOL = 'subagent_output'
|
|
61
|
+
|
|
55
62
|
export type LoopReason = 'consecutive' | 'windowed'
|
|
56
63
|
|
|
64
|
+
// Identifies the single observation a `check` recorded so a caller can retract
|
|
65
|
+
// exactly that one after learning post-execution it was not a loop (e.g. a
|
|
66
|
+
// `subagent_output` poll that returned `status: 'running'`). Narrower than
|
|
67
|
+
// `forgetTool`, which drops the whole tool window: retract undoes one call, so
|
|
68
|
+
// unrelated task_ids and terminal-result polls keep their accumulated signal.
|
|
69
|
+
export type LoopGuardReceipt = {
|
|
70
|
+
sessionId: string
|
|
71
|
+
tool: string
|
|
72
|
+
signature: string
|
|
73
|
+
windowSignature: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Post-execution classification of a `subagent_output` poll, fed back via
|
|
77
|
+
// `noteResult`. 'running' is a still-pending wait; 'terminal' is completed/failed
|
|
78
|
+
// — a repeated terminal poll is a real loop.
|
|
79
|
+
export type LoopObservedResult = 'running' | 'terminal'
|
|
80
|
+
|
|
57
81
|
export type LoopGuardDecision =
|
|
82
|
+
| { kind: 'ok'; receipt: LoopGuardReceipt }
|
|
83
|
+
| { kind: 'warn'; count: number; reason: LoopReason; message: string; receipt: LoopGuardReceipt }
|
|
84
|
+
| { kind: 'block'; count: number; reason: LoopReason; message: string; receipt: LoopGuardReceipt; deferable: boolean }
|
|
85
|
+
|
|
86
|
+
// A decision before its receipt is attached. The detector helpers produce these;
|
|
87
|
+
// `check` stamps the receipt on at its single return site.
|
|
88
|
+
type Verdict =
|
|
58
89
|
| { kind: 'ok' }
|
|
59
90
|
| { kind: 'warn'; count: number; reason: LoopReason; message: string }
|
|
60
91
|
| { kind: 'block'; count: number; reason: LoopReason; message: string }
|
|
@@ -71,6 +102,21 @@ export type LoopGuard = {
|
|
|
71
102
|
// premature polls poisoned the window. Narrower than `forget`, so an
|
|
72
103
|
// unrelated tool's accumulating loop on the same session is preserved.
|
|
73
104
|
forgetTool: (sessionId: string, tool: string) => void
|
|
105
|
+
// Undoes the one observation a prior `check` recorded, identified by its
|
|
106
|
+
// receipt. Pops that signature from the windowed history and, when the
|
|
107
|
+
// current consecutive streak is the call this receipt named (it is the most
|
|
108
|
+
// recent `check` on the session, since tool execution within a turn is
|
|
109
|
+
// sequential), rewinds the streak by one. Used post-execution for a
|
|
110
|
+
// `subagent_output` poll that returned `status: 'running'` — a still-pending
|
|
111
|
+
// wait, not a loop — so it never accumulates toward either detector.
|
|
112
|
+
retract: (receipt: LoopGuardReceipt) => void
|
|
113
|
+
// Records the post-execution class of a `subagent_output` poll. Once a
|
|
114
|
+
// signature is seen 'terminal', `check` stops marking its blocks `deferable`,
|
|
115
|
+
// so further identical polls hard-block PRE-execute instead of running again
|
|
116
|
+
// just to re-confirm a completed task. 'running' clears any prior terminal
|
|
117
|
+
// mark for that signature (a task can only move running→terminal, but a
|
|
118
|
+
// signature can be reused across episodes).
|
|
119
|
+
noteResult: (receipt: LoopGuardReceipt, result: LoopObservedResult) => void
|
|
74
120
|
}
|
|
75
121
|
|
|
76
122
|
type SessionState = {
|
|
@@ -84,6 +130,10 @@ type SessionState = {
|
|
|
84
130
|
// still present in the window.
|
|
85
131
|
window: string[]
|
|
86
132
|
windowWarned: Set<string>
|
|
133
|
+
// Exact signatures whose `subagent_output` poll has been observed terminal.
|
|
134
|
+
// A block on such a signature is enforced pre-execute (not deferred), so a
|
|
135
|
+
// completed task is not re-polled forever just to re-learn it is done.
|
|
136
|
+
termKnown: Set<string>
|
|
87
137
|
}
|
|
88
138
|
|
|
89
139
|
export type CreateLoopGuardOptions = {
|
|
@@ -134,7 +184,7 @@ export function createLoopGuard(options: CreateLoopGuardOptions = {}): LoopGuard
|
|
|
134
184
|
}
|
|
135
185
|
}
|
|
136
186
|
|
|
137
|
-
function evaluateConsecutive(state: SessionState, tool: string):
|
|
187
|
+
function evaluateConsecutive(state: SessionState, tool: string): Verdict {
|
|
138
188
|
if (state.count >= hardBlock) {
|
|
139
189
|
return {
|
|
140
190
|
kind: 'block',
|
|
@@ -150,32 +200,38 @@ export function createLoopGuard(options: CreateLoopGuardOptions = {}): LoopGuard
|
|
|
150
200
|
return { kind: 'ok' }
|
|
151
201
|
}
|
|
152
202
|
|
|
153
|
-
function evaluateWindowed(state: SessionState, tool: string, windowSig: string):
|
|
203
|
+
function evaluateWindowed(state: SessionState, tool: string, windowSig: string): Verdict {
|
|
154
204
|
const count = state.window.reduce((n, sig) => (sig === windowSig ? n + 1 : n), 0)
|
|
155
205
|
if (count >= windowHardBlock) {
|
|
156
|
-
return {
|
|
157
|
-
kind: 'block',
|
|
158
|
-
count,
|
|
159
|
-
reason: 'windowed',
|
|
160
|
-
message: formatWindowedBlockMessage(tool, count),
|
|
161
|
-
}
|
|
206
|
+
return { kind: 'block', count, reason: 'windowed', message: formatWindowedBlockMessage(tool, count) }
|
|
162
207
|
}
|
|
163
208
|
if (count >= windowSoftWarn && !state.windowWarned.has(windowSig)) {
|
|
164
209
|
state.windowWarned.add(windowSig)
|
|
165
|
-
return {
|
|
166
|
-
kind: 'warn',
|
|
167
|
-
count,
|
|
168
|
-
reason: 'windowed',
|
|
169
|
-
message: formatWindowedWarnMessage(tool, count),
|
|
170
|
-
}
|
|
210
|
+
return { kind: 'warn', count, reason: 'windowed', message: formatWindowedWarnMessage(tool, count) }
|
|
171
211
|
}
|
|
172
212
|
return { kind: 'ok' }
|
|
173
213
|
}
|
|
174
214
|
|
|
215
|
+
function resolveVerdict(state: SessionState, tool: string, windowSig: string): Verdict {
|
|
216
|
+
const consecutive = evaluateConsecutive(state, tool)
|
|
217
|
+
if (consecutive.kind === 'block') return consecutive
|
|
218
|
+
|
|
219
|
+
// Back-to-back identical calls are the consecutive detector's domain; let
|
|
220
|
+
// it own them so a tight streak doesn't also trip the windowed detector.
|
|
221
|
+
// The windowed detector exists for INTERLEAVED cycles, so it only acts
|
|
222
|
+
// when this call breaks the immediate streak (count === 1).
|
|
223
|
+
const windowed = state.count === 1 ? evaluateWindowed(state, tool, windowSig) : { kind: 'ok' as const }
|
|
224
|
+
if (windowed.kind === 'block') return windowed
|
|
225
|
+
if (consecutive.kind === 'warn') return consecutive
|
|
226
|
+
if (windowed.kind === 'warn') return windowed
|
|
227
|
+
return { kind: 'ok' }
|
|
228
|
+
}
|
|
229
|
+
|
|
175
230
|
return {
|
|
176
231
|
check(sessionId, tool, args) {
|
|
177
232
|
const signature = makeCallSignature(tool, args)
|
|
178
233
|
const windowSig = makeWindowSignature(tool, args)
|
|
234
|
+
const receipt: LoopGuardReceipt = { sessionId, tool, signature, windowSignature: windowSig }
|
|
179
235
|
const existing = sessions.get(sessionId)
|
|
180
236
|
|
|
181
237
|
const state: SessionState = existing ?? {
|
|
@@ -184,6 +240,7 @@ export function createLoopGuard(options: CreateLoopGuardOptions = {}): LoopGuard
|
|
|
184
240
|
warned: false,
|
|
185
241
|
window: [],
|
|
186
242
|
windowWarned: new Set(),
|
|
243
|
+
termKnown: new Set(),
|
|
187
244
|
}
|
|
188
245
|
|
|
189
246
|
if (state.signature !== signature) {
|
|
@@ -204,18 +261,14 @@ export function createLoopGuard(options: CreateLoopGuardOptions = {}): LoopGuard
|
|
|
204
261
|
|
|
205
262
|
touch(sessionId, state)
|
|
206
263
|
|
|
207
|
-
const
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (windowed.kind === 'block') return windowed
|
|
216
|
-
if (consecutive.kind === 'warn') return consecutive
|
|
217
|
-
if (windowed.kind === 'warn') return windowed
|
|
218
|
-
return { kind: 'ok' }
|
|
264
|
+
const verdict = resolveVerdict(state, tool, windowSig)
|
|
265
|
+
if (verdict.kind === 'block') {
|
|
266
|
+
// A `subagent_output` block is deferable (let the boundary call execute
|
|
267
|
+
// to learn its status) only until this signature has proven terminal.
|
|
268
|
+
const deferable = tool === SUBAGENT_OUTPUT_TOOL && !state.termKnown.has(signature)
|
|
269
|
+
return { ...verdict, receipt, deferable }
|
|
270
|
+
}
|
|
271
|
+
return { ...verdict, receipt }
|
|
219
272
|
},
|
|
220
273
|
reset(sessionId) {
|
|
221
274
|
sessions.delete(sessionId)
|
|
@@ -240,6 +293,39 @@ export function createLoopGuard(options: CreateLoopGuardOptions = {}): LoopGuard
|
|
|
240
293
|
state.count = 0
|
|
241
294
|
state.warned = false
|
|
242
295
|
}
|
|
296
|
+
for (const sig of state.termKnown) {
|
|
297
|
+
if (signatureBelongsToTool(sig, tool)) state.termKnown.delete(sig)
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
retract(receipt) {
|
|
301
|
+
const state = sessions.get(receipt.sessionId)
|
|
302
|
+
if (state === undefined) return
|
|
303
|
+
|
|
304
|
+
// Pop the receipt's windowed observation. It is the most recent push for
|
|
305
|
+
// this signature (retraction runs immediately after the call's execute,
|
|
306
|
+
// before any other tool runs on the session), so remove the last match.
|
|
307
|
+
const lastIdx = state.window.lastIndexOf(receipt.windowSignature)
|
|
308
|
+
if (lastIdx !== -1) {
|
|
309
|
+
state.window.splice(lastIdx, 1)
|
|
310
|
+
if (!state.window.includes(receipt.windowSignature)) {
|
|
311
|
+
state.windowWarned.delete(receipt.windowSignature)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Rewind the consecutive streak by one only if it is still the call this
|
|
316
|
+
// receipt named. A retracted soft-warned streak re-arms its warning so the
|
|
317
|
+
// next genuine repeat warns as if this call never happened.
|
|
318
|
+
if (state.signature === receipt.signature && state.count > 0) {
|
|
319
|
+
state.count -= 1
|
|
320
|
+
if (state.count < softWarn) state.warned = false
|
|
321
|
+
if (state.count === 0) state.signature = ''
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
noteResult(receipt, result) {
|
|
325
|
+
const state = sessions.get(receipt.sessionId)
|
|
326
|
+
if (state === undefined) return
|
|
327
|
+
if (result === 'terminal') state.termKnown.add(receipt.signature)
|
|
328
|
+
else state.termKnown.delete(receipt.signature)
|
|
243
329
|
},
|
|
244
330
|
}
|
|
245
331
|
}
|