kritya 0.8.2-beta
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/LICENSE +21 -0
- package/README.md +434 -0
- package/dist/agent/budget.js +25 -0
- package/dist/agent/compactor.js +78 -0
- package/dist/agent/contextWarning.js +6 -0
- package/dist/agent/killSwitch.js +111 -0
- package/dist/agent/loop.js +543 -0
- package/dist/agent/memory.js +145 -0
- package/dist/agent/plugins.js +408 -0
- package/dist/agent/skills.js +210 -0
- package/dist/agent/skillsCli.js +51 -0
- package/dist/agent/systemPrompt.js +122 -0
- package/dist/agent/tokens.js +55 -0
- package/dist/agent/toolExecutor.js +331 -0
- package/dist/agent/workflow.js +496 -0
- package/dist/agent/worktree.js +116 -0
- package/dist/atomicWrite.js +109 -0
- package/dist/audit/audit.js +230 -0
- package/dist/audit/cli.js +135 -0
- package/dist/commands/custom.js +69 -0
- package/dist/commands/mcpCommand.js +433 -0
- package/dist/commands/pluginsCommand.js +204 -0
- package/dist/commands/registry.js +739 -0
- package/dist/config/config.js +214 -0
- package/dist/config/debug.js +19 -0
- package/dist/config/models.js +82 -0
- package/dist/config/retention.js +24 -0
- package/dist/config/winAcl.js +38 -0
- package/dist/crash.js +102 -0
- package/dist/engine.js +113 -0
- package/dist/git/git.js +46 -0
- package/dist/headless.js +274 -0
- package/dist/hooks/hooks.js +122 -0
- package/dist/index.js +670 -0
- package/dist/lsp/client.js +413 -0
- package/dist/lsp/manager.js +0 -0
- package/dist/lsp/registry.js +62 -0
- package/dist/mcp/callback.js +141 -0
- package/dist/mcp/client.js +945 -0
- package/dist/mcp/login.js +117 -0
- package/dist/mcp/oauth.js +345 -0
- package/dist/mcp/servers.js +114 -0
- package/dist/mcp/spawnWin.js +86 -0
- package/dist/mcp/tokens.js +97 -0
- package/dist/mcp/transport.js +295 -0
- package/dist/net/urlSafety.js +158 -0
- package/dist/permissions/danger.js +96 -0
- package/dist/permissions/permissions.js +62 -0
- package/dist/permissions/rules.js +69 -0
- package/dist/plugins/discover.js +108 -0
- package/dist/plugins/mcp.js +80 -0
- package/dist/provider/client.js +460 -0
- package/dist/provider/switchyardClient.js +46 -0
- package/dist/provider/switchyardSidecar.js +245 -0
- package/dist/provider/textToolCalls.js +140 -0
- package/dist/repomap/repoMap.js +168 -0
- package/dist/repomap/symbols.js +190 -0
- package/dist/session/store.js +328 -0
- package/dist/shell/background.js +131 -0
- package/dist/shell/sandbox.js +396 -0
- package/dist/telemetry/metrics.js +172 -0
- package/dist/telemetry/otlp.js +128 -0
- package/dist/telemetry/tracer.js +240 -0
- package/dist/tools/askUser.js +64 -0
- package/dist/tools/bg.js +59 -0
- package/dist/tools/common.js +158 -0
- package/dist/tools/deepResearch.js +126 -0
- package/dist/tools/diff.js +70 -0
- package/dist/tools/document/docx.js +44 -0
- package/dist/tools/document/pdf.js +190 -0
- package/dist/tools/document/pptx.js +148 -0
- package/dist/tools/document/types.js +26 -0
- package/dist/tools/document/xlsx.js +87 -0
- package/dist/tools/document.js +389 -0
- package/dist/tools/edit.js +56 -0
- package/dist/tools/fetchUrl.js +218 -0
- package/dist/tools/fuzzyMatch.js +74 -0
- package/dist/tools/glob.js +34 -0
- package/dist/tools/grep.js +85 -0
- package/dist/tools/ignore.js +28 -0
- package/dist/tools/index.js +66 -0
- package/dist/tools/ls.js +27 -0
- package/dist/tools/lsp.js +239 -0
- package/dist/tools/notebook.js +215 -0
- package/dist/tools/read.js +32 -0
- package/dist/tools/repoMap.js +26 -0
- package/dist/tools/secretScan.js +143 -0
- package/dist/tools/shell.js +134 -0
- package/dist/tools/skills.js +69 -0
- package/dist/tools/subagent.js +57 -0
- package/dist/tools/tasks.js +43 -0
- package/dist/tools/webSearch.js +97 -0
- package/dist/tools/write.js +47 -0
- package/dist/tools/writeAgent.js +72 -0
- package/dist/trust/aiDisclosure.js +45 -0
- package/dist/trust/mcpTrust.js +111 -0
- package/dist/trust/trust.js +231 -0
- package/dist/types.js +1 -0
- package/dist/ui/AiDisclosurePrompt.js +23 -0
- package/dist/ui/App.js +449 -0
- package/dist/ui/Banner.js +71 -0
- package/dist/ui/ElicitationPrompt.js +46 -0
- package/dist/ui/Markdown.js +148 -0
- package/dist/ui/McpTrustPrompt.js +42 -0
- package/dist/ui/ModelPicker.js +19 -0
- package/dist/ui/PermissionPrompt.js +27 -0
- package/dist/ui/SelectList.js +24 -0
- package/dist/ui/Spinner.js +12 -0
- package/dist/ui/StatusLine.js +11 -0
- package/dist/ui/TranscriptItem.js +13 -0
- package/dist/ui/TrustPrompt.js +12 -0
- package/dist/ui/highlight.js +137 -0
- package/dist/ui/inline.js +241 -0
- package/dist/ui/mermaid.js +98 -0
- package/dist/ui/table.js +148 -0
- package/dist/ui/toolOutputPreview.js +34 -0
- package/dist/ui/useAgent.js +534 -0
- package/dist/ui/useKillSwitch.js +65 -0
- package/dist/ui/useSessionResume.js +39 -0
- package/dist/ui/useUsageBudget.js +149 -0
- package/dist/ui/viewport.js +71 -0
- package/dist/undo/undo.js +293 -0
- package/dist/version.js +4 -0
- package/package.json +122 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A session-wide emergency stop.
|
|
3
|
+
*
|
|
4
|
+
* Engaging it aborts whatever is in flight (the model stream, a running shell
|
|
5
|
+
* command, subagents) and refuses to start anything new until it's released.
|
|
6
|
+
* It is deliberately a shared object rather than a boolean on `Agent`:
|
|
7
|
+
* subagents are separate `Agent` instances, so a per-agent flag would stop the
|
|
8
|
+
* main loop while its children kept running — the exact failure a kill switch
|
|
9
|
+
* exists to prevent. One instance is handed to the main agent and to every
|
|
10
|
+
* agent it spawns.
|
|
11
|
+
*
|
|
12
|
+
* Session-only by design: it lives in memory and dies with the process. A
|
|
13
|
+
* restart comes up in the normal state.
|
|
14
|
+
*/
|
|
15
|
+
/** Thrown when a turn is refused or interrupted because the switch is engaged. */
|
|
16
|
+
export class KillSwitchError extends Error {
|
|
17
|
+
killReason;
|
|
18
|
+
constructor(killReason) {
|
|
19
|
+
super(killReason ? `Kill switch active: ${killReason}` : "Kill switch active");
|
|
20
|
+
this.killReason = killReason;
|
|
21
|
+
this.name = "KillSwitchError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export class KillSwitch {
|
|
25
|
+
/** Replaced on release, so a released switch hands out a fresh, un-aborted signal. */
|
|
26
|
+
controller = new AbortController();
|
|
27
|
+
engagedAt;
|
|
28
|
+
engagedReason;
|
|
29
|
+
listeners = new Set();
|
|
30
|
+
get active() {
|
|
31
|
+
return this.engagedAt !== undefined;
|
|
32
|
+
}
|
|
33
|
+
get reason() {
|
|
34
|
+
return this.engagedReason;
|
|
35
|
+
}
|
|
36
|
+
get activatedAt() {
|
|
37
|
+
return this.engagedAt;
|
|
38
|
+
}
|
|
39
|
+
/** Aborted for as long as the switch is engaged. Link work to it via `linkAbort`. */
|
|
40
|
+
get signal() {
|
|
41
|
+
return this.controller.signal;
|
|
42
|
+
}
|
|
43
|
+
/** Engage the switch. Returns false if it was already engaged (idempotent). */
|
|
44
|
+
engage(reason) {
|
|
45
|
+
if (this.active)
|
|
46
|
+
return false;
|
|
47
|
+
this.engagedAt = Date.now();
|
|
48
|
+
this.engagedReason = reason?.trim() || undefined;
|
|
49
|
+
this.controller.abort();
|
|
50
|
+
this.notify(true);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
/** Release it and re-arm a fresh signal. Returns false if it wasn't engaged. */
|
|
54
|
+
release() {
|
|
55
|
+
if (!this.active)
|
|
56
|
+
return false;
|
|
57
|
+
this.engagedAt = undefined;
|
|
58
|
+
this.engagedReason = undefined;
|
|
59
|
+
this.controller = new AbortController();
|
|
60
|
+
this.notify(false);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
/** Subscribe to engage/release. Returns an unsubscribe function. */
|
|
64
|
+
onChange(fn) {
|
|
65
|
+
this.listeners.add(fn);
|
|
66
|
+
return () => this.listeners.delete(fn);
|
|
67
|
+
}
|
|
68
|
+
/** Throw if the switch is engaged — the guard at the top of a turn. */
|
|
69
|
+
assertLive() {
|
|
70
|
+
if (this.active)
|
|
71
|
+
throw new KillSwitchError(this.reason);
|
|
72
|
+
}
|
|
73
|
+
notify(active) {
|
|
74
|
+
for (const listener of this.listeners) {
|
|
75
|
+
// A listener that throws must never stop the switch from taking effect
|
|
76
|
+
// for everyone else — that would defeat the whole point.
|
|
77
|
+
try {
|
|
78
|
+
listener(active);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// ignore
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Combine the kill switch's signal with a caller's own signal, so work aborts
|
|
88
|
+
* when either fires. (`AbortSignal.any` would do this, but it needs Node 20 and
|
|
89
|
+
* this package supports 18.) Call `dispose()` when the work finishes so the
|
|
90
|
+
* listeners don't accumulate on a long-lived switch.
|
|
91
|
+
*/
|
|
92
|
+
export function linkAbort(kill, signal) {
|
|
93
|
+
if (!signal)
|
|
94
|
+
return { signal: kill.signal, dispose: () => { } };
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
if (kill.signal.aborted || signal.aborted) {
|
|
97
|
+
controller.abort();
|
|
98
|
+
return { signal: controller.signal, dispose: () => { } };
|
|
99
|
+
}
|
|
100
|
+
const abort = () => controller.abort();
|
|
101
|
+
const killSignal = kill.signal; // capture: release() swaps in a new one
|
|
102
|
+
killSignal.addEventListener("abort", abort, { once: true });
|
|
103
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
104
|
+
return {
|
|
105
|
+
signal: controller.signal,
|
|
106
|
+
dispose: () => {
|
|
107
|
+
killSignal.removeEventListener("abort", abort);
|
|
108
|
+
signal.removeEventListener("abort", abort);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import { isContextOverflowError } from "../provider/client.js";
|
|
2
|
+
import { splitForCompaction, renderTranscript, fallbackSummary } from "./compactor.js";
|
|
3
|
+
import { estimateHistoryTokens, estimateTokens } from "./tokens.js";
|
|
4
|
+
import { buildSystemPrompt } from "./systemPrompt.js";
|
|
5
|
+
import { NOOP_TRACER } from "../telemetry/tracer.js";
|
|
6
|
+
import { NOOP_METER } from "../telemetry/metrics.js";
|
|
7
|
+
import { extractMemoryFacts, mergeProjectMemory, readProjectMemory } from "./memory.js";
|
|
8
|
+
import { KillSwitch, KillSwitchError, linkAbort } from "./killSwitch.js";
|
|
9
|
+
import { ToolExecutor } from "./toolExecutor.js";
|
|
10
|
+
export { ToolTimeoutError } from "./toolExecutor.js";
|
|
11
|
+
const DEFAULT_MAX_STEPS = 40;
|
|
12
|
+
const COMPACT_THRESHOLD = 0.8;
|
|
13
|
+
/** Below this, compaction has nothing to summarize away that isn't the live tail. */
|
|
14
|
+
const MIN_HISTORY_TO_COMPACT = 12;
|
|
15
|
+
/** Default ceiling on a single tool call; see Agent.toolTimeoutMs. */
|
|
16
|
+
const DEFAULT_TOOL_TIMEOUT_MS = 120_000;
|
|
17
|
+
export class Agent {
|
|
18
|
+
client;
|
|
19
|
+
getModel;
|
|
20
|
+
tools;
|
|
21
|
+
ctx;
|
|
22
|
+
permissions;
|
|
23
|
+
session;
|
|
24
|
+
history;
|
|
25
|
+
/** Prompt size of the most recent model call, from the API's usage report. */
|
|
26
|
+
lastPromptTokens = 0;
|
|
27
|
+
/** Model context window in tokens; configurable via config.contextWindow. */
|
|
28
|
+
contextWindow = 120_000;
|
|
29
|
+
/** Max model round-trips per request before stopping to ask the user. */
|
|
30
|
+
maxSteps = DEFAULT_MAX_STEPS;
|
|
31
|
+
/**
|
|
32
|
+
* How long one tool call may run before it's abandoned (config
|
|
33
|
+
* `toolTimeoutSeconds`). A tool with its own deadline opts out via
|
|
34
|
+
* `ToolDef.timeoutMs = 0`; 0 here disables the cap for every tool.
|
|
35
|
+
*/
|
|
36
|
+
toolTimeoutMs = DEFAULT_TOOL_TIMEOUT_MS;
|
|
37
|
+
/** When true, mutating tools are auto-denied (plan / read-only mode). */
|
|
38
|
+
planMode = false;
|
|
39
|
+
/**
|
|
40
|
+
* When true, mutating tools are auto-denied — the manual Shift+Tab
|
|
41
|
+
* read-only toggle. Independent of `planMode`, which the project
|
|
42
|
+
* workflow's own PLAN phase owns; the two are separate gates that can be
|
|
43
|
+
* on at the same time, each blocking on its own.
|
|
44
|
+
*/
|
|
45
|
+
dryRunMode = false;
|
|
46
|
+
/** When true, file-edit tools auto-approve without prompting (see ACCEPT_EDITS_TOOL_NAMES). */
|
|
47
|
+
acceptEdits = false;
|
|
48
|
+
/** Fires each time a tool call is auto-approved because of acceptEdits, for a UI counter. */
|
|
49
|
+
onAutoApprove;
|
|
50
|
+
/**
|
|
51
|
+
* When true, compaction also distills durable project facts out of the
|
|
52
|
+
* summarized-away messages and merges them into KRITYA.md, so useful
|
|
53
|
+
* context isn't lost once it scrolls out of the transcript. Off by
|
|
54
|
+
* default — only the main interactive agent opts in; subagents (which
|
|
55
|
+
* also run this same loop) should never write to the user's project
|
|
56
|
+
* memory on their own.
|
|
57
|
+
*/
|
|
58
|
+
autoMemory = false;
|
|
59
|
+
/** Optional user-configured shell hooks around tool calls and turn end. */
|
|
60
|
+
hooks;
|
|
61
|
+
/**
|
|
62
|
+
* The session's emergency stop. Every agent gets its own by default so a
|
|
63
|
+
* standalone one is never unguarded; whoever spawns subagents assigns the
|
|
64
|
+
* parent's instance to them, so one switch halts the whole tree.
|
|
65
|
+
*/
|
|
66
|
+
kill = new KillSwitch();
|
|
67
|
+
/**
|
|
68
|
+
* Append-only audit trail of permission decisions and tool executions. Left
|
|
69
|
+
* unset on subagents; only the main session records an audit log.
|
|
70
|
+
*/
|
|
71
|
+
audit;
|
|
72
|
+
/** OpenTelemetry-shaped tracer for the tool loop. No-op unless enabled. */
|
|
73
|
+
tracer = NOOP_TRACER;
|
|
74
|
+
/** OTLP metrics recorder for tool/turn durations. No-op unless enabled. */
|
|
75
|
+
meter = NOOP_METER;
|
|
76
|
+
/**
|
|
77
|
+
* Trace id of the most recent turn, so a caller that reports a result
|
|
78
|
+
* elsewhere (headless JSON) can point the reader at the matching spans.
|
|
79
|
+
* Undefined while telemetry is off, since the no-op tracer mints no ids.
|
|
80
|
+
*/
|
|
81
|
+
lastTraceId;
|
|
82
|
+
/**
|
|
83
|
+
* When set, the next turn's span nests under this one instead of starting a
|
|
84
|
+
* fresh trace. Used to fold a subagent's spans into the parent turn's trace
|
|
85
|
+
* — set by whoever spawns the subagent, using the parent's `turnSpan`.
|
|
86
|
+
*/
|
|
87
|
+
spanParent;
|
|
88
|
+
/** Extra attributes stamped on this agent's turn spans, e.g. to mark and label a subagent. */
|
|
89
|
+
spanAttributes;
|
|
90
|
+
/** The span for the in-flight turn, so tool spans can nest under it, and so a caller (e.g. a subagent spawner) can pass it down as another agent's `spanParent`. */
|
|
91
|
+
get turnSpan() {
|
|
92
|
+
return this.currentTurnSpan;
|
|
93
|
+
}
|
|
94
|
+
currentTurnSpan;
|
|
95
|
+
steerQueue = [];
|
|
96
|
+
/** Named points in the conversation for /rewind (in-memory, this session only). */
|
|
97
|
+
checkpoints = new Map();
|
|
98
|
+
/** Owns permission gating and execution for a turn's tool calls; see toolExecutor.ts. */
|
|
99
|
+
toolExecutor;
|
|
100
|
+
constructor(client, getModel, tools,
|
|
101
|
+
// Not private: read directly by ToolExecutor (see toolExecutor.ts), which
|
|
102
|
+
// is constructed with `this` as its host.
|
|
103
|
+
ctx, permissions, session, initialHistory = []) {
|
|
104
|
+
this.client = client;
|
|
105
|
+
this.getModel = getModel;
|
|
106
|
+
this.tools = tools;
|
|
107
|
+
this.ctx = ctx;
|
|
108
|
+
this.permissions = permissions;
|
|
109
|
+
this.session = session;
|
|
110
|
+
this.history = initialHistory;
|
|
111
|
+
this.toolExecutor = new ToolExecutor(this.tools, this);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Add tools to the live tool set. Used when an MCP server comes up after
|
|
115
|
+
* startup (e.g. `/mcp login` finished, or `/mcp add`), so a new server is
|
|
116
|
+
* usable in the current conversation instead of after a restart. Names are
|
|
117
|
+
* deduplicated: reconnecting a server must replace its tools, not shadow
|
|
118
|
+
* them with a second set pointing at a dead connection.
|
|
119
|
+
*/
|
|
120
|
+
addTools(defs) {
|
|
121
|
+
for (const def of defs) {
|
|
122
|
+
const idx = this.tools.findIndex((t) => t.name === def.name);
|
|
123
|
+
if (idx >= 0)
|
|
124
|
+
this.tools[idx] = def;
|
|
125
|
+
else
|
|
126
|
+
this.tools.push(def);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Drop tools by name (a server was removed or logged out of). */
|
|
130
|
+
removeTools(predicate) {
|
|
131
|
+
let removed = 0;
|
|
132
|
+
for (let i = this.tools.length - 1; i >= 0; i--) {
|
|
133
|
+
if (predicate(this.tools[i].name)) {
|
|
134
|
+
this.tools.splice(i, 1);
|
|
135
|
+
removed++;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return removed;
|
|
139
|
+
}
|
|
140
|
+
reset() {
|
|
141
|
+
this.history = [];
|
|
142
|
+
this.checkpoints.clear();
|
|
143
|
+
this.session.rotate();
|
|
144
|
+
}
|
|
145
|
+
/** Save (or overwrite) a named checkpoint at the current point in the
|
|
146
|
+
* conversation, paired with the undo turn so /rewind can restore both
|
|
147
|
+
* the transcript and the files together. */
|
|
148
|
+
saveCheckpoint(name, undoTurn) {
|
|
149
|
+
this.checkpoints.set(name, {
|
|
150
|
+
name,
|
|
151
|
+
historyLength: this.history.length,
|
|
152
|
+
undoTurn,
|
|
153
|
+
createdAt: Date.now(),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
getCheckpoint(name) {
|
|
157
|
+
return this.checkpoints.get(name);
|
|
158
|
+
}
|
|
159
|
+
/** Saved checkpoints, oldest first. */
|
|
160
|
+
listCheckpoints() {
|
|
161
|
+
return [...this.checkpoints.values()].sort((a, b) => a.createdAt - b.createdAt);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Drop every message after the first `length` and rewrite the session file
|
|
165
|
+
* to match. repairDanglingToolCalls patches any tool call the cut left
|
|
166
|
+
* without its result, so the trimmed history is still a valid request.
|
|
167
|
+
* Clamped to the current length: a checkpoint taken before a /compact can
|
|
168
|
+
* only ever be a no-op here, never grow history back.
|
|
169
|
+
*/
|
|
170
|
+
truncateHistory(length) {
|
|
171
|
+
if (length >= this.history.length)
|
|
172
|
+
return;
|
|
173
|
+
this.history = this.history.slice(0, length);
|
|
174
|
+
this.repairDanglingToolCalls(false);
|
|
175
|
+
this.session.overwrite(this.history);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Swap the underlying provider client mid-session — e.g. after the active
|
|
179
|
+
* provider exhausts its retries (see RetryExhaustedError in
|
|
180
|
+
* provider/client.ts) and the user picks a fallback via /provider. History
|
|
181
|
+
* lives on `this.history`/`this.session`, not the client, so nothing about
|
|
182
|
+
* the conversation is lost.
|
|
183
|
+
*/
|
|
184
|
+
setClient(client) {
|
|
185
|
+
this.client = client;
|
|
186
|
+
}
|
|
187
|
+
/** Record a user-side event (e.g. /undo, /web-search results) in the conversation. */
|
|
188
|
+
addUserNote(text) {
|
|
189
|
+
const msg = { role: "user", content: text };
|
|
190
|
+
this.history.push(msg);
|
|
191
|
+
this.session.append(msg);
|
|
192
|
+
}
|
|
193
|
+
/** Replace history with a resumed session's messages. */
|
|
194
|
+
loadHistory(messages) {
|
|
195
|
+
this.history = messages;
|
|
196
|
+
this.repairDanglingToolCalls(false);
|
|
197
|
+
this.session.start(this.history);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Insert stub results for tool calls that never got one (a cancelled or
|
|
201
|
+
* crashed turn, or a truncated session file). OpenAI-compatible APIs reject
|
|
202
|
+
* a history where an assistant message's tool_calls lack matching tool
|
|
203
|
+
* messages, so an unrepaired history would break every subsequent request.
|
|
204
|
+
*/
|
|
205
|
+
repairDanglingToolCalls(appendToSession) {
|
|
206
|
+
for (let i = 0; i < this.history.length; i++) {
|
|
207
|
+
const msg = this.history[i];
|
|
208
|
+
if (msg.role !== "assistant" || !("tool_calls" in msg) || !msg.tool_calls?.length)
|
|
209
|
+
continue;
|
|
210
|
+
const answered = new Set();
|
|
211
|
+
let j = i + 1;
|
|
212
|
+
while (j < this.history.length && this.history[j].role === "tool") {
|
|
213
|
+
answered.add(this.history[j].tool_call_id);
|
|
214
|
+
j++;
|
|
215
|
+
}
|
|
216
|
+
for (const call of msg.tool_calls) {
|
|
217
|
+
if (answered.has(call.id))
|
|
218
|
+
continue;
|
|
219
|
+
const stub = {
|
|
220
|
+
role: "tool",
|
|
221
|
+
tool_call_id: call.id,
|
|
222
|
+
content: "[interrupted — this tool call was cancelled before producing a result]",
|
|
223
|
+
};
|
|
224
|
+
this.history.splice(j, 0, stub);
|
|
225
|
+
if (appendToSession)
|
|
226
|
+
this.session.append(stub);
|
|
227
|
+
j++;
|
|
228
|
+
}
|
|
229
|
+
i = j - 1;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Queue a user correction typed while the agent is working; it is absorbed before the next model call. */
|
|
233
|
+
queueSteer(text) {
|
|
234
|
+
this.steerQueue.push(text);
|
|
235
|
+
}
|
|
236
|
+
/** Fraction of the context window used by the last model call (0..1). */
|
|
237
|
+
contextUsage() {
|
|
238
|
+
return Math.min(1, this.lastPromptTokens / this.contextWindow);
|
|
239
|
+
}
|
|
240
|
+
drainSteerQueue() {
|
|
241
|
+
while (this.steerQueue.length) {
|
|
242
|
+
const msg = {
|
|
243
|
+
role: "user",
|
|
244
|
+
content: `[Mid-task instruction from the user — adjust course accordingly]\n${this.steerQueue.shift()}`,
|
|
245
|
+
};
|
|
246
|
+
this.history.push(msg);
|
|
247
|
+
this.session.append(msg);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Summarize older history into one message, keeping the recent tail. This
|
|
252
|
+
* discards the original messages permanently (only the summary survives),
|
|
253
|
+
* so it's recorded as a lifecycle event — both a span and an audit record —
|
|
254
|
+
* distinct from the `llm.chat` span for the summarization call itself.
|
|
255
|
+
*/
|
|
256
|
+
async compact(signal) {
|
|
257
|
+
// Compaction calls the model directly rather than going through runTurn,
|
|
258
|
+
// so it needs its own gate.
|
|
259
|
+
this.kill.assertLive();
|
|
260
|
+
const compactSpan = this.tracer.startSpan("agent.compact", { parent: this.currentTurnSpan });
|
|
261
|
+
const tokensBefore = this.lastPromptTokens;
|
|
262
|
+
try {
|
|
263
|
+
const note = await this.doCompact(signal, compactSpan);
|
|
264
|
+
compactSpan.setStatus("OK");
|
|
265
|
+
return note;
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
compactSpan.setStatus("ERROR", err instanceof Error ? err.message : String(err));
|
|
269
|
+
throw err;
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
compactSpan.setAttribute("kritya.prompt_tokens_before", tokensBefore);
|
|
273
|
+
compactSpan.setAttribute("kritya.prompt_tokens_after", this.lastPromptTokens);
|
|
274
|
+
compactSpan.end();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async doCompact(signal, compactSpan) {
|
|
278
|
+
const { toSummarize, keep } = splitForCompaction(this.history);
|
|
279
|
+
if (!toSummarize.length) {
|
|
280
|
+
compactSpan.setAttribute("kritya.skipped", true);
|
|
281
|
+
return "Nothing to compact yet.";
|
|
282
|
+
}
|
|
283
|
+
// Compaction is a recovery action, and it is reached almost exclusively
|
|
284
|
+
// when things are already going badly — a nearly-full context, often a
|
|
285
|
+
// provider that is rate-limiting or timing out. Letting the summarization
|
|
286
|
+
// call's failure propagate would abort the turn at exactly the point the
|
|
287
|
+
// user most needs it to survive, so a failure degrades to a mechanical
|
|
288
|
+
// record of the dropped messages instead. Cancellation is not a failure of
|
|
289
|
+
// this kind and still propagates: the user asked to stop.
|
|
290
|
+
let summarized;
|
|
291
|
+
let degraded = false;
|
|
292
|
+
try {
|
|
293
|
+
const result = await this.client.chat(this.getModel(), [
|
|
294
|
+
{
|
|
295
|
+
role: "system",
|
|
296
|
+
content: "You summarize coding-session transcripts. Produce a dense briefing: the user's goals, " +
|
|
297
|
+
"key decisions, files created/modified (with paths), commands run and their outcomes, " +
|
|
298
|
+
"current state, and open items. Plain text, no preamble.",
|
|
299
|
+
},
|
|
300
|
+
{ role: "user", content: renderTranscript(toSummarize) },
|
|
301
|
+
], [], { onTextDelta: () => { }, onReasoningDelta: () => { } }, signal, { tracer: this.tracer, parent: compactSpan });
|
|
302
|
+
summarized = result.text.trim() || "(summary unavailable)";
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
if (signal?.aborted ||
|
|
306
|
+
err?.name === "AbortError" ||
|
|
307
|
+
err instanceof KillSwitchError)
|
|
308
|
+
throw err;
|
|
309
|
+
this.kill.assertLive();
|
|
310
|
+
degraded = true;
|
|
311
|
+
summarized = fallbackSummary(toSummarize);
|
|
312
|
+
compactSpan.addEvent("compact.degraded", {
|
|
313
|
+
"kritya.reason": err instanceof Error ? err.message : String(err),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
const summary = summarized;
|
|
317
|
+
this.history = [
|
|
318
|
+
{
|
|
319
|
+
role: "user",
|
|
320
|
+
content: degraded ? summary : `[Conversation summary of earlier work]\n${summary}`,
|
|
321
|
+
},
|
|
322
|
+
...keep,
|
|
323
|
+
];
|
|
324
|
+
this.session.rotate();
|
|
325
|
+
this.session.start(this.history);
|
|
326
|
+
// Rough size estimate until the next model call reports real usage.
|
|
327
|
+
this.lastPromptTokens = estimateHistoryTokens(this.history);
|
|
328
|
+
const note = degraded
|
|
329
|
+
? `Compacted context WITHOUT a summary (the summarization request failed): dropped ` +
|
|
330
|
+
`${toSummarize.length} messages, kept the last ${keep.length}. Earlier detail is lost — ` +
|
|
331
|
+
`re-read anything the next step depends on.`
|
|
332
|
+
: `Compacted context: summarized ${toSummarize.length} messages, kept the last ${keep.length}.`;
|
|
333
|
+
compactSpan.setAttribute("kritya.messages_summarized", toSummarize.length);
|
|
334
|
+
compactSpan.setAttribute("kritya.messages_kept", keep.length);
|
|
335
|
+
compactSpan.setAttribute("kritya.degraded", degraded);
|
|
336
|
+
this.audit?.logTool({
|
|
337
|
+
tool: "compact",
|
|
338
|
+
summary: degraded
|
|
339
|
+
? `dropped ${toSummarize.length} message(s) without a summary, kept ${keep.length}`
|
|
340
|
+
: `summarized ${toSummarize.length} message(s), kept ${keep.length}`,
|
|
341
|
+
outcome: degraded ? "error" : "ok",
|
|
342
|
+
});
|
|
343
|
+
// No point asking the model for memory facts when it just failed to
|
|
344
|
+
// summarize, and no honest source to distill them from either.
|
|
345
|
+
if (!this.autoMemory || degraded)
|
|
346
|
+
return note;
|
|
347
|
+
// Best-effort: memory distillation is a nice-to-have, never let it fail
|
|
348
|
+
// (or block on) the compaction it's piggybacking on.
|
|
349
|
+
try {
|
|
350
|
+
const existing = readProjectMemory(this.ctx.workspace);
|
|
351
|
+
const facts = await extractMemoryFacts(this.client, this.getModel(), toSummarize, renderTranscript(toSummarize), existing, signal);
|
|
352
|
+
const added = mergeProjectMemory(this.ctx.workspace, facts);
|
|
353
|
+
if (added.length) {
|
|
354
|
+
return `${note}\nUpdated KRITYA.md with ${added.length} new project fact(s):\n${added.map((f) => ` - ${f}`).join("\n")}`;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
// memory distillation is best-effort; compaction itself still succeeded
|
|
359
|
+
}
|
|
360
|
+
return note;
|
|
361
|
+
}
|
|
362
|
+
async runTurn(userText, handlers, signal, images = []) {
|
|
363
|
+
// Refuse before touching history: a killed session shouldn't accumulate
|
|
364
|
+
// turns it never ran.
|
|
365
|
+
this.kill.assertLive();
|
|
366
|
+
this.ctx.undo?.beginTurn?.();
|
|
367
|
+
// A previous turn may have been cancelled mid-tool-call; repair before the
|
|
368
|
+
// next API request or the provider will reject the whole history. Stubs
|
|
369
|
+
// land at the tail here, so appending them to the session keeps its order.
|
|
370
|
+
this.repairDanglingToolCalls(true);
|
|
371
|
+
const userMsg = images.length
|
|
372
|
+
? {
|
|
373
|
+
role: "user",
|
|
374
|
+
content: [
|
|
375
|
+
{ type: "text", text: userText },
|
|
376
|
+
...images.map((url) => ({ type: "image_url", image_url: { url } })),
|
|
377
|
+
],
|
|
378
|
+
}
|
|
379
|
+
: { role: "user", content: userText };
|
|
380
|
+
this.history.push(userMsg);
|
|
381
|
+
this.session.append(userMsg);
|
|
382
|
+
const systemMsg = {
|
|
383
|
+
role: "system",
|
|
384
|
+
content: buildSystemPrompt(this.ctx.workspace, this.planMode, this.dryRunMode, this.ctx.trustWorkspace !== false),
|
|
385
|
+
};
|
|
386
|
+
const turnSpan = this.tracer.startSpan("agent.turn", {
|
|
387
|
+
parent: this.spanParent,
|
|
388
|
+
attributes: {
|
|
389
|
+
"kritya.model": this.getModel(),
|
|
390
|
+
"kritya.session_id": this.session.id,
|
|
391
|
+
...this.spanAttributes,
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
const turnStartedAtMs = Date.now();
|
|
395
|
+
this.currentTurnSpan = turnSpan;
|
|
396
|
+
this.lastTraceId = turnSpan.traceId || undefined;
|
|
397
|
+
// Everything below runs against a signal that fires on the caller's cancel
|
|
398
|
+
// *or* the kill switch, so engaging it tears down the in-flight model
|
|
399
|
+
// stream and any running tool immediately, not at the next checkpoint.
|
|
400
|
+
const link = linkAbort(this.kill, signal);
|
|
401
|
+
try {
|
|
402
|
+
await this.runLoop(systemMsg, handlers, link.signal);
|
|
403
|
+
turnSpan.setStatus("OK");
|
|
404
|
+
}
|
|
405
|
+
catch (err) {
|
|
406
|
+
turnSpan.setStatus("ERROR", err instanceof Error ? err.message : String(err));
|
|
407
|
+
// An abort raised by the kill switch reports itself as one, so callers
|
|
408
|
+
// don't file it under "user pressed Esc".
|
|
409
|
+
if (this.kill.active && !(err instanceof KillSwitchError)) {
|
|
410
|
+
throw new KillSwitchError(this.kill.reason);
|
|
411
|
+
}
|
|
412
|
+
throw err;
|
|
413
|
+
}
|
|
414
|
+
finally {
|
|
415
|
+
link.dispose();
|
|
416
|
+
this.currentTurnSpan = undefined;
|
|
417
|
+
turnSpan.end();
|
|
418
|
+
this.meter.histogram("kritya.turn.duration_ms").record(Date.now() - turnStartedAtMs);
|
|
419
|
+
await this.hooks?.runStop(turnSpan);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
async runLoop(systemMsg, handlers, signal) {
|
|
423
|
+
for (let i = 0; i < this.maxSteps; i++) {
|
|
424
|
+
// Checked ahead of the generic abort so a kill mid-turn is reported as
|
|
425
|
+
// a kill rather than an ordinary cancellation.
|
|
426
|
+
this.kill.assertLive();
|
|
427
|
+
if (signal?.aborted)
|
|
428
|
+
throw new DOMException("Aborted", "AbortError");
|
|
429
|
+
this.drainSteerQueue();
|
|
430
|
+
// Pre-flight. Auto-compaction below only fires on a *reported* prompt
|
|
431
|
+
// size, which is always one request behind: a single large tool result
|
|
432
|
+
// can push the next request past the window, and a context overflow is a
|
|
433
|
+
// hard 400 that no retry recovers. Estimating what we're about to send
|
|
434
|
+
// catches that before it's sent.
|
|
435
|
+
await this.compactIfPredictedOverflow(systemMsg, handlers, signal);
|
|
436
|
+
const send = () => this.client.chat(this.getModel(), [systemMsg, ...this.history], this.tools, {
|
|
437
|
+
onTextDelta: handlers.onTextDelta,
|
|
438
|
+
onReasoningDelta: handlers.onReasoningDelta,
|
|
439
|
+
onRetry: handlers.onRetry,
|
|
440
|
+
}, signal, { tracer: this.tracer, parent: this.currentTurnSpan });
|
|
441
|
+
let result;
|
|
442
|
+
try {
|
|
443
|
+
result = await send();
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
// The one 400 with a remedy: the prompt doesn't fit. Compact and send
|
|
447
|
+
// the (now shorter) history once more rather than failing the turn.
|
|
448
|
+
// Only ever once per step — if it still doesn't fit, something is
|
|
449
|
+
// wrong that compaction can't fix and the error should surface.
|
|
450
|
+
if (!isContextOverflowError(err) || !this.canCompact())
|
|
451
|
+
throw err;
|
|
452
|
+
this.currentTurnSpan?.addEvent("context.overflow_recovery");
|
|
453
|
+
const note = await this.compact(signal);
|
|
454
|
+
handlers.onToolEnd("compact", "compact", "Context overflow — compacted and retrying", note, false);
|
|
455
|
+
result = await send();
|
|
456
|
+
}
|
|
457
|
+
if (result.usage) {
|
|
458
|
+
this.lastPromptTokens = result.usage.promptTokens;
|
|
459
|
+
handlers.onUsage({ ...result.usage, servedModel: result.model });
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
// Some providers omit usage on streamed responses. Estimate from text
|
|
463
|
+
// length so the context meter and auto-compaction don't stall at 0,
|
|
464
|
+
// and still report it (marked `estimated`) so cost/budget tracking
|
|
465
|
+
// isn't silently blind for the whole session — but the caller can
|
|
466
|
+
// tell an estimate from a real number and show it as approximate.
|
|
467
|
+
this.lastPromptTokens = estimateHistoryTokens([systemMsg, ...this.history]);
|
|
468
|
+
handlers.onUsage({
|
|
469
|
+
promptTokens: this.lastPromptTokens,
|
|
470
|
+
completionTokens: estimateTokens(result.text),
|
|
471
|
+
estimated: true,
|
|
472
|
+
servedModel: result.model,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
this.history.push(result.message);
|
|
476
|
+
this.session.append(result.message);
|
|
477
|
+
if (result.text.trim())
|
|
478
|
+
handlers.onAssistantText(result.text);
|
|
479
|
+
if (!result.toolCalls.length)
|
|
480
|
+
return;
|
|
481
|
+
const outputs = await this.toolExecutor.executeToolCalls(result.toolCalls, handlers, signal);
|
|
482
|
+
for (let k = 0; k < result.toolCalls.length; k++) {
|
|
483
|
+
const toolMsg = {
|
|
484
|
+
role: "tool",
|
|
485
|
+
tool_call_id: result.toolCalls[k].id,
|
|
486
|
+
content: outputs[k],
|
|
487
|
+
};
|
|
488
|
+
this.history.push(toolMsg);
|
|
489
|
+
this.session.append(toolMsg);
|
|
490
|
+
}
|
|
491
|
+
if (this.contextUsage() > COMPACT_THRESHOLD && this.canCompact()) {
|
|
492
|
+
await this.tryCompact("Auto-compacted context", handlers, signal);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
handlers.onAssistantText(`[Stopped after ${this.maxSteps} steps — the safety limit for one request. ` +
|
|
496
|
+
`Send "continue" to keep going, or raise "maxSteps" in ~/.kritya/config.json.]`);
|
|
497
|
+
}
|
|
498
|
+
/** Whether there is enough history for compaction to actually shrink anything. */
|
|
499
|
+
canCompact() {
|
|
500
|
+
return this.history.length > MIN_HISTORY_TO_COMPACT;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Compact, treating a failure as recoverable. doCompact already degrades to
|
|
504
|
+
* a summary-free record rather than throwing, so reaching the catch here
|
|
505
|
+
* means something more unusual went wrong — and even then, continuing with
|
|
506
|
+
* an over-full context (which may still fit, or may be caught by the
|
|
507
|
+
* overflow recovery on the next call) beats destroying the turn over a
|
|
508
|
+
* housekeeping step. Cancellation still propagates.
|
|
509
|
+
*/
|
|
510
|
+
async tryCompact(label, handlers, signal) {
|
|
511
|
+
try {
|
|
512
|
+
const note = await this.compact(signal);
|
|
513
|
+
handlers.onToolEnd("compact", "compact", label, note, false);
|
|
514
|
+
}
|
|
515
|
+
catch (err) {
|
|
516
|
+
if (signal?.aborted ||
|
|
517
|
+
err?.name === "AbortError" ||
|
|
518
|
+
err instanceof KillSwitchError)
|
|
519
|
+
throw err;
|
|
520
|
+
this.kill.assertLive();
|
|
521
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
522
|
+
this.currentTurnSpan?.addEvent("compact.failed", { "kritya.reason": msg });
|
|
523
|
+
handlers.onToolEnd("compact", "compact", label, `Compaction failed: ${msg}`, true);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Compact ahead of a request whose estimated size already exceeds the
|
|
528
|
+
* threshold. The estimate covers the history and system prompt but not the
|
|
529
|
+
* tool schemas, which are a fixed cost the threshold's headroom absorbs.
|
|
530
|
+
*/
|
|
531
|
+
async compactIfPredictedOverflow(systemMsg, handlers, signal) {
|
|
532
|
+
if (!this.canCompact())
|
|
533
|
+
return;
|
|
534
|
+
const predicted = estimateHistoryTokens([systemMsg, ...this.history]);
|
|
535
|
+
if (predicted <= this.contextWindow * COMPACT_THRESHOLD)
|
|
536
|
+
return;
|
|
537
|
+
this.currentTurnSpan?.addEvent("context.preflight_compact", {
|
|
538
|
+
"kritya.predicted_prompt_tokens": predicted,
|
|
539
|
+
"kritya.context_window": this.contextWindow,
|
|
540
|
+
});
|
|
541
|
+
await this.tryCompact("Compacted context before sending (predicted overflow)", handlers, signal);
|
|
542
|
+
}
|
|
543
|
+
}
|