@cjhyy/code-shell-core 0.7.0-beta.1 → 0.7.1
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/dist/arena/arena.d.ts +2 -0
- package/dist/arena/arena.js +30 -1
- package/dist/arena/phases/adjudication.d.ts +2 -1
- package/dist/arena/phases/adjudication.js +2 -1
- package/dist/arena/phases/build-consensus.d.ts +2 -1
- package/dist/arena/phases/build-consensus.js +3 -1
- package/dist/arena/phases/cross-review.d.ts +3 -1
- package/dist/arena/phases/cross-review.js +8 -2
- package/dist/arena/phases/debate-rounds.d.ts +2 -1
- package/dist/arena/phases/debate-rounds.js +4 -2
- package/dist/arena/phases/participant-research.d.ts +2 -1
- package/dist/arena/phases/participant-research.js +3 -1
- package/dist/arena/phases/planning-detail-expansion.d.ts +2 -1
- package/dist/arena/phases/planning-detail-expansion.js +2 -1
- package/dist/arena/planner.d.ts +2 -1
- package/dist/arena/planner.js +2 -1
- package/dist/arena/types.d.ts +5 -1
- package/dist/cc-orchestrator/agent-adapter.d.ts +2 -0
- package/dist/cc-orchestrator/agent-adapter.js +4 -0
- package/dist/cc-orchestrator/codex-session-history.d.ts +14 -1
- package/dist/cc-orchestrator/codex-session-history.js +64 -4
- package/dist/cc-orchestrator/external-agent-changes.js +22 -5
- package/dist/cc-orchestrator/external-agent-driver.d.ts +1 -1
- package/dist/cc-orchestrator/external-agent-driver.js +202 -38
- package/dist/cc-orchestrator/session-history.d.ts +35 -0
- package/dist/cc-orchestrator/session-history.js +96 -13
- package/dist/cli/agent-server-tcp.js +13 -2
- package/dist/context/manager.d.ts +3 -3
- package/dist/context/manager.js +6 -6
- package/dist/context/token-counter.js +5 -3
- package/dist/context/tool-result-storage.d.ts +6 -0
- package/dist/context/tool-result-storage.js +25 -4
- package/dist/credentials/access.d.ts +11 -1
- package/dist/credentials/access.js +77 -1
- package/dist/credentials/index.d.ts +3 -1
- package/dist/credentials/index.js +2 -0
- package/dist/credentials/oauth.d.ts +25 -0
- package/dist/credentials/oauth.js +179 -0
- package/dist/credentials/store.d.ts +2 -1
- package/dist/credentials/store.js +19 -8
- package/dist/credentials/types.d.ts +84 -1
- package/dist/credentials/types.js +16 -1
- package/dist/engine/engine.d.ts +67 -34
- package/dist/engine/engine.js +448 -247
- package/dist/engine/goal.d.ts +19 -0
- package/dist/engine/goal.js +16 -6
- package/dist/engine/input-attachments.js +156 -13
- package/dist/engine/run-image-input.d.ts +22 -0
- package/dist/engine/run-image-input.js +195 -0
- package/dist/engine/session-title.d.ts +2 -1
- package/dist/engine/session-title.js +4 -1
- package/dist/engine/steer-queue.d.ts +3 -1
- package/dist/engine/steer-queue.js +10 -2
- package/dist/engine/turn-loop.d.ts +48 -1
- package/dist/engine/turn-loop.js +307 -37
- package/dist/engine/types.d.ts +6 -2
- package/dist/git/worktree/crud.d.ts +3 -0
- package/dist/git/worktree/crud.js +32 -3
- package/dist/git/worktree/git-exec.d.ts +2 -2
- package/dist/git/worktree/git-exec.js +47 -11
- package/dist/git/worktree/query.d.ts +8 -7
- package/dist/git/worktree/query.js +27 -20
- package/dist/hooks/events.d.ts +3 -0
- package/dist/hooks/events.js +0 -3
- package/dist/hooks/goal-stop-hook.d.ts +44 -2
- package/dist/hooks/goal-stop-hook.js +775 -52
- package/dist/hooks/registry.js +3 -0
- package/dist/hooks/shell-runner.d.ts +12 -1
- package/dist/hooks/shell-runner.js +160 -9
- package/dist/index.d.ts +7 -6
- package/dist/index.js +6 -5
- package/dist/llm/client-base.js +12 -10
- package/dist/llm/types.d.ts +12 -5
- package/dist/plugins/pluginCommandHook.d.ts +4 -4
- package/dist/plugins/pluginCommandHook.js +111 -13
- package/dist/preset/index.js +14 -4
- package/dist/protocol/chat-session-manager.d.ts +13 -2
- package/dist/protocol/chat-session-manager.js +90 -18
- package/dist/protocol/chat-session.d.ts +12 -0
- package/dist/protocol/chat-session.js +30 -5
- package/dist/protocol/client.d.ts +5 -2
- package/dist/protocol/client.js +22 -1
- package/dist/protocol/server.d.ts +25 -11
- package/dist/protocol/server.js +291 -73
- package/dist/protocol/types.d.ts +36 -2
- package/dist/protocol/types.js +2 -0
- package/dist/services/dream-consolidation.d.ts +3 -0
- package/dist/services/dream-consolidation.js +4 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.js +1 -1
- package/dist/services/oauth.d.ts +34 -10
- package/dist/services/oauth.js +233 -98
- package/dist/session/session-manager.d.ts +35 -6
- package/dist/session/session-manager.js +396 -27
- package/dist/session/transcript.d.ts +30 -1
- package/dist/session/transcript.js +119 -4
- package/dist/tool-system/builtin/agent-notifications.d.ts +11 -4
- package/dist/tool-system/builtin/agent-notifications.js +19 -7
- package/dist/tool-system/builtin/agent.js +5 -1
- package/dist/tool-system/builtin/arena.js +1 -0
- package/dist/tool-system/builtin/background-jobs.d.ts +28 -5
- package/dist/tool-system/builtin/background-jobs.js +109 -7
- package/dist/tool-system/builtin/background-work.d.ts +6 -1
- package/dist/tool-system/builtin/background-work.js +5 -1
- package/dist/tool-system/builtin/bash.d.ts +3 -5
- package/dist/tool-system/builtin/bash.js +10 -5
- package/dist/tool-system/builtin/browser-tools.d.ts +2 -2
- package/dist/tool-system/builtin/cron-list.definition.d.ts +3 -0
- package/dist/tool-system/builtin/cron-list.definition.js +6 -0
- package/dist/tool-system/builtin/cron.d.ts +1 -2
- package/dist/tool-system/builtin/cron.js +9 -7
- package/dist/tool-system/builtin/drive-claude-code.d.ts +7 -0
- package/dist/tool-system/builtin/drive-claude-code.js +307 -20
- package/dist/tool-system/builtin/edit.d.ts +2 -1
- package/dist/tool-system/builtin/edit.js +12 -4
- package/dist/tool-system/builtin/generate-video.d.ts +4 -0
- package/dist/tool-system/builtin/generate-video.js +138 -21
- package/dist/tool-system/builtin/glob.d.ts +2 -1
- package/dist/tool-system/builtin/glob.js +28 -3
- package/dist/tool-system/builtin/grep.d.ts +1 -0
- package/dist/tool-system/builtin/grep.js +82 -17
- package/dist/tool-system/builtin/index.d.ts +25 -11
- package/dist/tool-system/builtin/index.js +60 -5
- package/dist/tool-system/builtin/sleep.d.ts +1 -2
- package/dist/tool-system/builtin/sleep.definition.d.ts +8 -0
- package/dist/tool-system/builtin/sleep.definition.js +28 -0
- package/dist/tool-system/builtin/sleep.js +1 -22
- package/dist/tool-system/builtin/video-providers.d.ts +12 -15
- package/dist/tool-system/builtin/video-providers.js +1 -0
- package/dist/tool-system/builtin/view-image.d.ts +2 -2
- package/dist/tool-system/builtin/web-fetch.js +44 -3
- package/dist/tool-system/builtin/worktree.js +25 -7
- package/dist/tool-system/builtin/write.d.ts +2 -1
- package/dist/tool-system/builtin/write.js +14 -4
- package/dist/tool-system/context.d.ts +35 -5
- package/dist/tool-system/executor.js +24 -8
- package/dist/tool-system/mcp-manager.d.ts +20 -2
- package/dist/tool-system/mcp-manager.js +111 -12
- package/dist/tool-system/path-policy.d.ts +19 -0
- package/dist/tool-system/path-policy.js +62 -1
- package/dist/tool-system/permission.d.ts +43 -3
- package/dist/tool-system/permission.js +383 -30
- package/dist/tool-system/registry.d.ts +3 -2
- package/dist/tool-system/registry.js +52 -34
- package/dist/types.d.ts +38 -7
- package/package.json +1 -1
package/dist/engine/goal.d.ts
CHANGED
|
@@ -47,6 +47,23 @@ export interface GoalConfig {
|
|
|
47
47
|
*/
|
|
48
48
|
setAtMs?: number;
|
|
49
49
|
}
|
|
50
|
+
/** A forced stop outcome for a Goal run. Some outcomes intentionally preserve the Goal. */
|
|
51
|
+
export type GoalTerminationReason = "stop_blocks_exhausted" | "token_budget_exhausted" | "time_budget_exhausted" | "max_turns_exhausted" | "judge_prompt_too_large";
|
|
52
|
+
/** Outcomes that permanently close one concrete persisted Goal instance. */
|
|
53
|
+
export type PersistedGoalTerminationReason = Exclude<GoalTerminationReason, "judge_prompt_too_large">;
|
|
54
|
+
/**
|
|
55
|
+
* Persisted terminal marker used to prevent a stale whole-state writer from
|
|
56
|
+
* making an exhausted goal armable again. `objective + setAtMs` is the goal
|
|
57
|
+
* instance identity; `terminatedAtMs` only orders competing tombstones.
|
|
58
|
+
*/
|
|
59
|
+
export interface GoalTerminal {
|
|
60
|
+
objective: string;
|
|
61
|
+
setAtMs?: number;
|
|
62
|
+
reason: PersistedGoalTerminationReason;
|
|
63
|
+
terminatedAtMs?: number;
|
|
64
|
+
}
|
|
65
|
+
/** True only when both values identify the same concrete goal instance. */
|
|
66
|
+
export declare function isSameGoalInstance(left: Pick<GoalConfig, "objective" | "setAtMs"> | undefined, right: Pick<GoalConfig, "objective" | "setAtMs"> | undefined): boolean;
|
|
50
67
|
/**
|
|
51
68
|
* Default consecutive-stop-block cap for goal runs. The real safety net for an
|
|
52
69
|
* unattended goal is the token/time budget + maxTurns; this cap only stops a
|
|
@@ -185,3 +202,5 @@ export declare function recordGoalUsage(tracker: GoalBudgetTracker, turnTokens:
|
|
|
185
202
|
* pass the current clock (and tests pass a fixed value).
|
|
186
203
|
*/
|
|
187
204
|
export declare function goalBudgetExceeded(tracker: GoalBudgetTracker, nowMs: number): boolean;
|
|
205
|
+
/** Identify which goal budget forced termination, preserving token-first precedence. */
|
|
206
|
+
export declare function goalBudgetTerminationReason(tracker: GoalBudgetTracker, nowMs: number): Extract<GoalTerminationReason, "token_budget_exhausted" | "time_budget_exhausted"> | undefined;
|
package/dist/engine/goal.js
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* it accumulates total tokens across turns and stamps a wall-clock start,
|
|
11
11
|
* so an unattended goal run can't burn tokens or wall time without bound.
|
|
12
12
|
*/
|
|
13
|
+
/** True only when both values identify the same concrete goal instance. */
|
|
14
|
+
export function isSameGoalInstance(left, right) {
|
|
15
|
+
return !!left && !!right && left.objective === right.objective && left.setAtMs === right.setAtMs;
|
|
16
|
+
}
|
|
13
17
|
/**
|
|
14
18
|
* Default consecutive-stop-block cap for goal runs. The real safety net for an
|
|
15
19
|
* unattended goal is the token/time budget + maxTurns; this cap only stops a
|
|
@@ -94,7 +98,7 @@ export function normalizeGoal(raw) {
|
|
|
94
98
|
*/
|
|
95
99
|
export function resolveGoalSetAt(explicitObjective, stored, nowMs) {
|
|
96
100
|
const sameGoalContinues = !!stored && stored.objective === explicitObjective;
|
|
97
|
-
return sameGoalContinues ? stored.setAtMs ?? nowMs : nowMs;
|
|
101
|
+
return sameGoalContinues ? (stored.setAtMs ?? nowMs) : nowMs;
|
|
98
102
|
}
|
|
99
103
|
/** Interactive (no-goal) turn ceiling — a single prompt rarely needs more. */
|
|
100
104
|
export const INTERACTIVE_DEFAULT_MAX_TURNS = 100;
|
|
@@ -187,10 +191,16 @@ export function recordGoalUsage(tracker, turnTokens) {
|
|
|
187
191
|
* pass the current clock (and tests pass a fixed value).
|
|
188
192
|
*/
|
|
189
193
|
export function goalBudgetExceeded(tracker, nowMs) {
|
|
194
|
+
return goalBudgetTerminationReason(tracker, nowMs) !== undefined;
|
|
195
|
+
}
|
|
196
|
+
/** Identify which goal budget forced termination, preserving token-first precedence. */
|
|
197
|
+
export function goalBudgetTerminationReason(tracker, nowMs) {
|
|
190
198
|
const { tokenBudget, timeBudgetMs } = tracker.goal;
|
|
191
|
-
if (typeof tokenBudget === "number" && tracker.tokensUsed > tokenBudget)
|
|
192
|
-
return
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
199
|
+
if (typeof tokenBudget === "number" && tracker.tokensUsed > tokenBudget) {
|
|
200
|
+
return "token_budget_exhausted";
|
|
201
|
+
}
|
|
202
|
+
if (typeof timeBudgetMs === "number" && nowMs - tracker.startedAtMs > timeBudgetMs) {
|
|
203
|
+
return "time_budget_exhausted";
|
|
204
|
+
}
|
|
205
|
+
return undefined;
|
|
196
206
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
3
3
|
import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { logger } from "../logging/logger.js";
|
|
4
5
|
import { classifyPath } from "../tool-system/path-policy.js";
|
|
5
6
|
import { enforceImageBytePolicy } from "./image-policy.js";
|
|
6
7
|
const IMAGE_MIME_BY_EXT = {
|
|
@@ -23,8 +24,18 @@ export async function buildInputAttachmentContext(attachments, cwd, options = {}
|
|
|
23
24
|
const images = [];
|
|
24
25
|
const errors = [];
|
|
25
26
|
const pendingImages = [];
|
|
27
|
+
const diagnostics = [];
|
|
26
28
|
let hasStructuredImageAttachments = false;
|
|
29
|
+
logger.info("engine.run.input_attachments.start", {
|
|
30
|
+
attachmentCount: attachments.length,
|
|
31
|
+
expectedSessionId: expectedSessionId ?? null,
|
|
32
|
+
includeImageBytes,
|
|
33
|
+
});
|
|
27
34
|
if (!expectedSessionId) {
|
|
35
|
+
logger.warn("engine.run.input_attachments.invalid_session", {
|
|
36
|
+
attachmentCount: attachments.length,
|
|
37
|
+
reason: "missing_expected_session_id",
|
|
38
|
+
});
|
|
28
39
|
return {
|
|
29
40
|
text: "",
|
|
30
41
|
images: [],
|
|
@@ -33,6 +44,10 @@ export async function buildInputAttachmentContext(attachments, cwd, options = {}
|
|
|
33
44
|
};
|
|
34
45
|
}
|
|
35
46
|
if (!isSafeSessionPathSegment(expectedSessionId)) {
|
|
47
|
+
logger.warn("engine.run.input_attachments.invalid_session", {
|
|
48
|
+
attachmentCount: attachments.length,
|
|
49
|
+
reason: "unsafe_expected_session_id",
|
|
50
|
+
});
|
|
36
51
|
return {
|
|
37
52
|
text: "",
|
|
38
53
|
images: [],
|
|
@@ -40,36 +55,89 @@ export async function buildInputAttachmentContext(attachments, cwd, options = {}
|
|
|
40
55
|
hasStructuredImageAttachments: false,
|
|
41
56
|
};
|
|
42
57
|
}
|
|
43
|
-
for (const attachment of attachments) {
|
|
44
|
-
if (!attachment || typeof attachment !== "object")
|
|
58
|
+
for (const [index, attachment] of attachments.entries()) {
|
|
59
|
+
if (!attachment || typeof attachment !== "object") {
|
|
60
|
+
errors.push(`attachment at index ${index} is not an object`);
|
|
61
|
+
diagnostics.push({
|
|
62
|
+
index,
|
|
63
|
+
id: "(unknown)",
|
|
64
|
+
kind: typeof attachment,
|
|
65
|
+
path: null,
|
|
66
|
+
mime: null,
|
|
67
|
+
stage: "invalid_metadata",
|
|
68
|
+
});
|
|
45
69
|
continue;
|
|
46
|
-
|
|
70
|
+
}
|
|
71
|
+
const id = stringField(attachment.id) ?? "(unknown)";
|
|
72
|
+
const kind = stringField(attachment.kind) ?? "(unknown)";
|
|
73
|
+
const declaredMime = stringField(attachment.mime);
|
|
74
|
+
const displayPath = firstString(attachment.path, attachment.relPath, attachment.absPath);
|
|
47
75
|
if (!displayPath) {
|
|
48
|
-
errors.push(`attachment ${
|
|
76
|
+
errors.push(`attachment ${id} has no valid path`);
|
|
77
|
+
diagnostics.push({
|
|
78
|
+
index,
|
|
79
|
+
id,
|
|
80
|
+
kind,
|
|
81
|
+
path: null,
|
|
82
|
+
mime: declaredMime ?? null,
|
|
83
|
+
stage: "invalid_path_metadata",
|
|
84
|
+
});
|
|
49
85
|
continue;
|
|
50
86
|
}
|
|
51
87
|
if (attachment.sessionId !== expectedSessionId) {
|
|
52
|
-
errors.push(`attachment ${
|
|
88
|
+
errors.push(`attachment ${id === "(unknown)" ? displayPath : id} session mismatch: expected ${expectedSessionId}, got ${attachment.sessionId}`);
|
|
89
|
+
diagnostics.push({
|
|
90
|
+
index,
|
|
91
|
+
id,
|
|
92
|
+
kind,
|
|
93
|
+
path: displayPath,
|
|
94
|
+
mime: declaredMime ?? null,
|
|
95
|
+
stage: "session_mismatch",
|
|
96
|
+
});
|
|
53
97
|
continue;
|
|
54
98
|
}
|
|
55
|
-
const resolved = resolveAttachmentPath(attachment, cwd);
|
|
99
|
+
const resolved = resolveAttachmentPath(attachment, cwd, displayPath);
|
|
56
100
|
let info;
|
|
57
101
|
try {
|
|
58
102
|
info = await stat(resolved);
|
|
59
103
|
}
|
|
60
104
|
catch (err) {
|
|
61
|
-
errors.push(`attachment ${
|
|
105
|
+
errors.push(`attachment ${id === "(unknown)" ? displayPath : id} stat failed: ${err.message}`);
|
|
106
|
+
diagnostics.push({
|
|
107
|
+
index,
|
|
108
|
+
id,
|
|
109
|
+
kind,
|
|
110
|
+
path: displayPath,
|
|
111
|
+
mime: declaredMime ?? null,
|
|
112
|
+
stage: "stat_failed",
|
|
113
|
+
});
|
|
62
114
|
continue;
|
|
63
115
|
}
|
|
64
116
|
const policy = classifyPath(resolved, { workspaceRoot: cwdReal, operation: "read" });
|
|
65
117
|
if (policy.decision !== "allow") {
|
|
66
|
-
errors.push(`attachment ${
|
|
118
|
+
errors.push(`attachment ${id === "(unknown)" ? displayPath : id} blocked by path policy: ${policy.reason}`);
|
|
119
|
+
diagnostics.push({
|
|
120
|
+
index,
|
|
121
|
+
id,
|
|
122
|
+
kind,
|
|
123
|
+
path: displayPath,
|
|
124
|
+
mime: declaredMime ?? null,
|
|
125
|
+
stage: "path_policy_blocked",
|
|
126
|
+
});
|
|
67
127
|
continue;
|
|
68
128
|
}
|
|
69
129
|
const realPath = policy.resolvedPath;
|
|
70
130
|
const stagedPathError = await validateStagedAttachmentPath(attachment, realPath, cwdReal, expectedSessionId, displayPath);
|
|
71
131
|
if (stagedPathError) {
|
|
72
132
|
errors.push(stagedPathError);
|
|
133
|
+
diagnostics.push({
|
|
134
|
+
index,
|
|
135
|
+
id,
|
|
136
|
+
kind,
|
|
137
|
+
path: displayPath,
|
|
138
|
+
mime: declaredMime ?? null,
|
|
139
|
+
stage: "staged_path_blocked",
|
|
140
|
+
});
|
|
73
141
|
continue;
|
|
74
142
|
}
|
|
75
143
|
if (attachment.kind === "directory" || info.isDirectory()) {
|
|
@@ -83,35 +151,85 @@ export async function buildInputAttachmentContext(attachments, cwd, options = {}
|
|
|
83
151
|
...tree.lines,
|
|
84
152
|
`</attached-directory>`,
|
|
85
153
|
].join("\n"));
|
|
154
|
+
diagnostics.push({
|
|
155
|
+
index,
|
|
156
|
+
id,
|
|
157
|
+
kind: "directory",
|
|
158
|
+
path: displayPath,
|
|
159
|
+
mime: declaredMime ?? null,
|
|
160
|
+
stage: tree.truncated ? "directory_metadata_truncated" : "directory_metadata",
|
|
161
|
+
});
|
|
86
162
|
continue;
|
|
87
163
|
}
|
|
88
164
|
if (!info.isFile()) {
|
|
89
|
-
errors.push(`attachment ${
|
|
165
|
+
errors.push(`attachment ${id === "(unknown)" ? displayPath : id} is not a regular file or directory`);
|
|
166
|
+
diagnostics.push({
|
|
167
|
+
index,
|
|
168
|
+
id,
|
|
169
|
+
kind,
|
|
170
|
+
path: displayPath,
|
|
171
|
+
mime: declaredMime ?? null,
|
|
172
|
+
stage: "unsupported_filesystem_entry",
|
|
173
|
+
});
|
|
90
174
|
continue;
|
|
91
175
|
}
|
|
92
|
-
const mime =
|
|
176
|
+
const mime = declaredMime || IMAGE_MIME_BY_EXT[extname(realPath).toLowerCase()];
|
|
93
177
|
if (attachment.kind === "image") {
|
|
94
178
|
hasStructuredImageAttachments = true;
|
|
95
179
|
if (!includeImageBytes || attachment.vision?.include === false) {
|
|
96
180
|
textBlocks.push(formatFileMetadata(attachment, displayPath, realPath, info.size, mime));
|
|
181
|
+
diagnostics.push({
|
|
182
|
+
index,
|
|
183
|
+
id,
|
|
184
|
+
kind,
|
|
185
|
+
path: displayPath,
|
|
186
|
+
mime: mime ?? null,
|
|
187
|
+
stage: "image_metadata_only",
|
|
188
|
+
});
|
|
97
189
|
continue;
|
|
98
190
|
}
|
|
99
191
|
if (!mime ||
|
|
100
192
|
!mime.startsWith("image/") ||
|
|
101
193
|
!IMAGE_MIME_BY_EXT[extname(realPath).toLowerCase()]) {
|
|
102
|
-
errors.push(`image attachment ${
|
|
194
|
+
errors.push(`image attachment ${id === "(unknown)" ? displayPath : id} has unsupported image type`);
|
|
195
|
+
diagnostics.push({
|
|
196
|
+
index,
|
|
197
|
+
id,
|
|
198
|
+
kind,
|
|
199
|
+
path: displayPath,
|
|
200
|
+
mime: mime ?? null,
|
|
201
|
+
stage: "unsupported_image_type",
|
|
202
|
+
});
|
|
103
203
|
continue;
|
|
104
204
|
}
|
|
205
|
+
const diagnostic = {
|
|
206
|
+
index,
|
|
207
|
+
id,
|
|
208
|
+
kind,
|
|
209
|
+
path: displayPath,
|
|
210
|
+
mime,
|
|
211
|
+
stage: "image_pending_bytes",
|
|
212
|
+
};
|
|
105
213
|
pendingImages.push({
|
|
106
214
|
attachment,
|
|
107
215
|
displayPath,
|
|
108
216
|
realPath,
|
|
109
217
|
mime,
|
|
110
218
|
size: info.size,
|
|
219
|
+
diagnostic,
|
|
111
220
|
});
|
|
221
|
+
diagnostics.push(diagnostic);
|
|
112
222
|
continue;
|
|
113
223
|
}
|
|
114
224
|
textBlocks.push(formatFileMetadata(attachment, displayPath, realPath, info.size, mime));
|
|
225
|
+
diagnostics.push({
|
|
226
|
+
index,
|
|
227
|
+
id,
|
|
228
|
+
kind,
|
|
229
|
+
path: displayPath,
|
|
230
|
+
mime: mime ?? null,
|
|
231
|
+
stage: "file_metadata",
|
|
232
|
+
});
|
|
115
233
|
}
|
|
116
234
|
if (errors.length === 0 && includeImageBytes && pendingImages.length > 0) {
|
|
117
235
|
const verdict = enforceImageBytePolicy(pendingImages.map((image) => ({
|
|
@@ -121,6 +239,11 @@ export async function buildInputAttachmentContext(attachments, cwd, options = {}
|
|
|
121
239
|
})));
|
|
122
240
|
if (!verdict.ok) {
|
|
123
241
|
errors.push(`image attachment size policy failed: ${verdict.message}`);
|
|
242
|
+
for (const diagnostic of diagnostics) {
|
|
243
|
+
if (diagnostic.stage === "image_pending_bytes") {
|
|
244
|
+
diagnostic.stage = "image_size_policy_failed";
|
|
245
|
+
}
|
|
246
|
+
}
|
|
124
247
|
}
|
|
125
248
|
else {
|
|
126
249
|
for (const image of pendingImages) {
|
|
@@ -130,6 +253,7 @@ export async function buildInputAttachmentContext(attachments, cwd, options = {}
|
|
|
130
253
|
}
|
|
131
254
|
catch (err) {
|
|
132
255
|
errors.push(`image attachment ${image.attachment.id || image.displayPath} read failed: ${err.message}`);
|
|
256
|
+
image.diagnostic.stage = "image_read_failed";
|
|
133
257
|
continue;
|
|
134
258
|
}
|
|
135
259
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
@@ -145,17 +269,36 @@ export async function buildInputAttachmentContext(attachments, cwd, options = {}
|
|
|
145
269
|
origin: image.attachment.origin,
|
|
146
270
|
sessionId: image.attachment.sessionId,
|
|
147
271
|
});
|
|
272
|
+
image.diagnostic.stage = "image_bytes_loaded";
|
|
148
273
|
}
|
|
149
274
|
}
|
|
150
275
|
}
|
|
276
|
+
logger.info("engine.run.input_attachments.complete", {
|
|
277
|
+
attachmentCount: attachments.length,
|
|
278
|
+
textBlockCount: textBlocks.length,
|
|
279
|
+
imageCount: images.length,
|
|
280
|
+
errorCount: errors.length,
|
|
281
|
+
attachments: diagnostics,
|
|
282
|
+
});
|
|
151
283
|
return { text: textBlocks.join("\n\n"), images, errors, hasStructuredImageAttachments };
|
|
152
284
|
}
|
|
153
|
-
function resolveAttachmentPath(attachment, cwd) {
|
|
154
|
-
const
|
|
285
|
+
function resolveAttachmentPath(attachment, cwd, displayPath) {
|
|
286
|
+
const visionPath = attachment.vision && typeof attachment.vision === "object"
|
|
287
|
+
? stringField(attachment.vision.mediaPath)
|
|
288
|
+
: undefined;
|
|
289
|
+
const candidate = visionPath ??
|
|
290
|
+
firstString(attachment.absPath, attachment.relPath, attachment.path) ??
|
|
291
|
+
displayPath;
|
|
155
292
|
if (isAbsolute(candidate))
|
|
156
293
|
return candidate;
|
|
157
294
|
return resolve(cwd, candidate);
|
|
158
295
|
}
|
|
296
|
+
function firstString(...values) {
|
|
297
|
+
return values.find((value) => typeof value === "string" && value.length > 0);
|
|
298
|
+
}
|
|
299
|
+
function stringField(value) {
|
|
300
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
301
|
+
}
|
|
159
302
|
async function validateStagedAttachmentPath(attachment, realPath, cwdReal, expectedSessionId, displayPath) {
|
|
160
303
|
if (!isStagedAttachmentReference(attachment, realPath, cwdReal))
|
|
161
304
|
return undefined;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { InputAttachmentMeta } from "../protocol/types.js";
|
|
2
|
+
import type { ContentBlock, LLMConfig } from "../types.js";
|
|
3
|
+
import { type ParsedTask } from "./parse-task.js";
|
|
4
|
+
import type { EngineResult } from "./types.js";
|
|
5
|
+
export interface PreparedRunImageInput {
|
|
6
|
+
parsedTask: ParsedTask;
|
|
7
|
+
taskText: string;
|
|
8
|
+
}
|
|
9
|
+
export type PrepareRunImageInputResult = ({
|
|
10
|
+
ok: true;
|
|
11
|
+
} & PreparedRunImageInput) | {
|
|
12
|
+
ok: false;
|
|
13
|
+
result: EngineResult;
|
|
14
|
+
};
|
|
15
|
+
export declare function prepareRunImageInput(args: {
|
|
16
|
+
task: string;
|
|
17
|
+
cwd: string;
|
|
18
|
+
llm: Pick<LLMConfig, "provider" | "providerKind" | "model">;
|
|
19
|
+
sessionId?: string;
|
|
20
|
+
attachments?: readonly InputAttachmentMeta[];
|
|
21
|
+
}): Promise<PrepareRunImageInputResult>;
|
|
22
|
+
export declare function buildRunUserMessageContent(parsedTask: ParsedTask, cwd: string, taskText: string): string | ContentBlock[];
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { capabilitiesFor } from "../llm/capabilities/index.js";
|
|
4
|
+
import { logger } from "../logging/logger.js";
|
|
5
|
+
import { tryCompressImages } from "./image-compression.js";
|
|
6
|
+
import { byteLengthFromBase64, collectAttachedImagePaths, dropOversizedImages, enforceImagePolicy, } from "./image-policy.js";
|
|
7
|
+
import { buildInputAttachmentContext } from "./input-attachments.js";
|
|
8
|
+
import { parseTaskWithImages } from "./parse-task.js";
|
|
9
|
+
export async function prepareRunImageInput(args) {
|
|
10
|
+
const { task, cwd, llm, sessionId } = args;
|
|
11
|
+
// Parse `<codeshell-image>` blocks out of the raw task string before
|
|
12
|
+
// any other gate looks at it. Two concerns:
|
|
13
|
+
// 1. The noise detector sees raw base64 as gibberish and would reject the
|
|
14
|
+
// whole turn — split images out first so it only inspects prose.
|
|
15
|
+
// 2. Models that don't accept vision must be refused immediately, with the
|
|
16
|
+
// image bytes intact for the user to retry on another model.
|
|
17
|
+
let parsedTask;
|
|
18
|
+
try {
|
|
19
|
+
parsedTask = parseTaskWithImages(task);
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
const msg = err.message;
|
|
23
|
+
logger.warn("engine.run.image_parse_failed", { error: msg });
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
result: {
|
|
27
|
+
text: `ERROR: image attachment is malformed (${msg}). Drop the image and try again, or re-attach it.`,
|
|
28
|
+
reason: "image_error",
|
|
29
|
+
sessionId: sessionId ?? "image-parse-failed",
|
|
30
|
+
turnCount: 0,
|
|
31
|
+
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const cap = capabilitiesFor((llm.providerKind ?? llm.provider), llm.model);
|
|
36
|
+
let attachmentContext;
|
|
37
|
+
try {
|
|
38
|
+
attachmentContext = await buildInputAttachmentContext(args.attachments, cwd, {
|
|
39
|
+
includeImageBytes: cap.supportsVision,
|
|
40
|
+
expectedSessionId: sessionId,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
45
|
+
logger.error("engine.run.input_attachment_exception", {
|
|
46
|
+
stage: "build_input_attachment_context",
|
|
47
|
+
attachmentCount: args.attachments?.length ?? 0,
|
|
48
|
+
error: message,
|
|
49
|
+
});
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
result: {
|
|
53
|
+
text: `ERROR: input attachment could not be prepared (${message}). Re-attach it or choose a path inside the workspace.`,
|
|
54
|
+
reason: "image_error",
|
|
55
|
+
sessionId: sessionId ?? "input-attachment-exception",
|
|
56
|
+
turnCount: 0,
|
|
57
|
+
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (attachmentContext.errors.length > 0) {
|
|
62
|
+
const detail = attachmentContext.errors.join("; ");
|
|
63
|
+
logger.warn("engine.run.input_attachment_failed", { error: detail });
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
result: {
|
|
67
|
+
text: `ERROR: input attachment could not be read (${detail}). Re-attach it or choose a path inside the workspace.`,
|
|
68
|
+
reason: "image_error",
|
|
69
|
+
sessionId: sessionId ?? "input-attachment-failed",
|
|
70
|
+
turnCount: 0,
|
|
71
|
+
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (attachmentContext.text || attachmentContext.hasStructuredImageAttachments) {
|
|
76
|
+
parsedTask = {
|
|
77
|
+
text: [parsedTask.text, attachmentContext.text].filter(Boolean).join("\n\n"),
|
|
78
|
+
images: [...parsedTask.images, ...attachmentContext.images],
|
|
79
|
+
hasImages: parsedTask.hasImages ||
|
|
80
|
+
attachmentContext.images.length > 0 ||
|
|
81
|
+
attachmentContext.hasStructuredImageAttachments,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (parsedTask.hasImages) {
|
|
85
|
+
if (!cap.supportsVision) {
|
|
86
|
+
logger.warn("engine.run.vision_not_supported", {
|
|
87
|
+
provider: llm.provider,
|
|
88
|
+
model: llm.model,
|
|
89
|
+
imageCount: parsedTask.images.length,
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
result: {
|
|
94
|
+
text: `ERROR: model "${llm.model}" does not accept image input. ` +
|
|
95
|
+
`Switch to a vision-capable model (e.g. gpt-4o, claude-sonnet, gemini-1.5-pro) and resend.`,
|
|
96
|
+
reason: "image_error",
|
|
97
|
+
sessionId: sessionId ?? "vision-not-supported",
|
|
98
|
+
turnCount: 0,
|
|
99
|
+
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// Size gate. Hosts are expected to pre-compress to IMAGE_TARGETS; this is
|
|
104
|
+
// the last cheap refusal point before a provider request carries the bytes.
|
|
105
|
+
let verdict = enforceImagePolicy(parsedTask.images);
|
|
106
|
+
if (!verdict.ok && verdict.code === "image_too_large") {
|
|
107
|
+
const compressed = await tryCompressImages(parsedTask.images);
|
|
108
|
+
if (compressed.anyCompressed) {
|
|
109
|
+
parsedTask.images = compressed.images;
|
|
110
|
+
logger.info("engine.run.image_compressed", {
|
|
111
|
+
before: verdict.offender?.bytes,
|
|
112
|
+
after: compressed.images.reduce((s, i) => s + byteLengthFromBase64(i.base64), 0),
|
|
113
|
+
});
|
|
114
|
+
verdict = enforceImagePolicy(parsedTask.images);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// After compression, anything still over the per-image cap is dropped with
|
|
118
|
+
// a textual placeholder so oversized bytes do not poison conversation history.
|
|
119
|
+
if (!verdict.ok && verdict.code === "image_too_large") {
|
|
120
|
+
const drop = dropOversizedImages(parsedTask.images);
|
|
121
|
+
if (drop.droppedCount > 0) {
|
|
122
|
+
parsedTask.images = drop.kept;
|
|
123
|
+
parsedTask.hasImages = drop.kept.length > 0;
|
|
124
|
+
parsedTask.text = drop.placeholder + "\n\n" + parsedTask.text;
|
|
125
|
+
logger.warn("engine.run.image_dropped", {
|
|
126
|
+
droppedCount: drop.droppedCount,
|
|
127
|
+
keptCount: drop.kept.length,
|
|
128
|
+
});
|
|
129
|
+
verdict = enforceImagePolicy(parsedTask.images);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!verdict.ok) {
|
|
133
|
+
logger.warn("engine.run.image_policy_failed", {
|
|
134
|
+
code: verdict.code,
|
|
135
|
+
imageCount: verdict.totals.imageCount,
|
|
136
|
+
totalBytes: verdict.totals.totalBytes,
|
|
137
|
+
offender: verdict.offender,
|
|
138
|
+
});
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
result: {
|
|
142
|
+
text: `ERROR: ${verdict.message}`,
|
|
143
|
+
reason: "image_error",
|
|
144
|
+
sessionId: sessionId ?? `image-policy-${verdict.code}`,
|
|
145
|
+
turnCount: 0,
|
|
146
|
+
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// For downstream noise-detection + transcript persistence we want the text
|
|
152
|
+
// portion only. Image bytes ride in parsedTask.images and re-enter the message
|
|
153
|
+
// tree below.
|
|
154
|
+
const taskText = parsedTask.text;
|
|
155
|
+
return { ok: true, parsedTask, taskText };
|
|
156
|
+
}
|
|
157
|
+
export function buildRunUserMessageContent(parsedTask, cwd, taskText) {
|
|
158
|
+
// Compose the user-turn payload once so resume + cold paths agree on shape.
|
|
159
|
+
// When an attached image came from a workspace file, surface that path to the
|
|
160
|
+
// model as text so file-oriented tools can use the real on-disk path.
|
|
161
|
+
const attachedPaths = collectAttachedImagePaths(parsedTask.images, (name) => (isAbsolute(name) ? name : join(cwd, name)), existsSync);
|
|
162
|
+
const pathHint = attachedPaths.length > 0
|
|
163
|
+
? `\n\n<attached-image-paths>\n${attachedPaths.join("\n")}\n</attached-image-paths>\n` +
|
|
164
|
+
`(上面附带的图片在工作区的真实路径,如需把它们作为工具输入(例如 GenerateImage 的 referenceImages、图生图参考图),直接使用这些路径。)`
|
|
165
|
+
: "";
|
|
166
|
+
const userMessageContent = parsedTask.hasImages
|
|
167
|
+
? [
|
|
168
|
+
...(parsedTask.text || pathHint
|
|
169
|
+
? [{ type: "text", text: `${parsedTask.text}${pathHint}` }]
|
|
170
|
+
: []),
|
|
171
|
+
...parsedTask.images.map((img) => ({
|
|
172
|
+
type: "image",
|
|
173
|
+
source: {
|
|
174
|
+
type: "base64",
|
|
175
|
+
media_type: img.mime,
|
|
176
|
+
data: img.base64,
|
|
177
|
+
},
|
|
178
|
+
})),
|
|
179
|
+
]
|
|
180
|
+
: taskText;
|
|
181
|
+
logger.info("engine.run.user_message_content_built", {
|
|
182
|
+
contentShape: Array.isArray(userMessageContent) ? "content_blocks" : "text",
|
|
183
|
+
textBlockCount: Array.isArray(userMessageContent)
|
|
184
|
+
? userMessageContent.filter((block) => block.type === "text").length
|
|
185
|
+
: userMessageContent
|
|
186
|
+
? 1
|
|
187
|
+
: 0,
|
|
188
|
+
imageBlockCount: Array.isArray(userMessageContent)
|
|
189
|
+
? userMessageContent.filter((block) => block.type === "image").length
|
|
190
|
+
: 0,
|
|
191
|
+
attachedPathCount: attachedPaths.length,
|
|
192
|
+
textLength: taskText.length,
|
|
193
|
+
});
|
|
194
|
+
return userMessageContent;
|
|
195
|
+
}
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* resolves the aux client and wiring; this only does the LLM call + cleanup.
|
|
6
6
|
*/
|
|
7
7
|
import type { LLMClientBase } from "../llm/client-base.js";
|
|
8
|
+
import type { TokenUsage } from "../types.js";
|
|
8
9
|
/**
|
|
9
10
|
* Ask the aux client for a one-line title from the first user message + first
|
|
10
11
|
* assistant reply. Returns the cleaned title, or null on any failure / empty
|
|
11
12
|
* output (caller treats null as "keep existing title").
|
|
12
13
|
*/
|
|
13
|
-
export declare function buildSessionTitle(client: LLMClientBase, firstUserText: string, firstAssistantText: string): Promise<string | null>;
|
|
14
|
+
export declare function buildSessionTitle(client: LLMClientBase, firstUserText: string, firstAssistantText: string, recordBilledUsage?: (usage: TokenUsage) => void): Promise<string | null>;
|
|
@@ -13,7 +13,7 @@ function clean(raw) {
|
|
|
13
13
|
* assistant reply. Returns the cleaned title, or null on any failure / empty
|
|
14
14
|
* output (caller treats null as "keep existing title").
|
|
15
15
|
*/
|
|
16
|
-
export async function buildSessionTitle(client, firstUserText, firstAssistantText) {
|
|
16
|
+
export async function buildSessionTitle(client, firstUserText, firstAssistantText, recordBilledUsage) {
|
|
17
17
|
try {
|
|
18
18
|
const prompt = `User: ${firstUserText.slice(0, 2000)}\n\n` +
|
|
19
19
|
`Assistant: ${firstAssistantText.slice(0, 2000)}\n\n` +
|
|
@@ -23,8 +23,11 @@ export async function buildSessionTitle(client, firstUserText, firstAssistantTex
|
|
|
23
23
|
messages: [{ role: "user", content: prompt }],
|
|
24
24
|
tools: [],
|
|
25
25
|
maxTokens: 64,
|
|
26
|
+
requestVisible: false,
|
|
26
27
|
reasoning: { mode: "off" },
|
|
27
28
|
});
|
|
29
|
+
if (resp.usage)
|
|
30
|
+
recordBilledUsage?.(resp.usage);
|
|
28
31
|
const title = clean(resp.text ?? "");
|
|
29
32
|
return title.length > 0 ? title : null;
|
|
30
33
|
}
|
|
@@ -6,13 +6,15 @@
|
|
|
6
6
|
* it. Pure + side-effect-free so it unit-tests without the Engine/TurnLoop
|
|
7
7
|
* harness (same rationale as the renderer's queuedInput.ts).
|
|
8
8
|
*/
|
|
9
|
+
import type { InputAttachmentMeta } from "../protocol/types.js";
|
|
9
10
|
export interface SteerItem {
|
|
10
11
|
id: string;
|
|
11
12
|
text: string;
|
|
12
13
|
clientMessageId?: string;
|
|
14
|
+
attachments?: InputAttachmentMeta[];
|
|
13
15
|
}
|
|
14
16
|
/** Append a steer entry. Blank text is dropped (returns the list unchanged). */
|
|
15
|
-
export declare function enqueueSteerItem(list: SteerItem[], id: string, text: string, clientMessageId?: string): SteerItem[];
|
|
17
|
+
export declare function enqueueSteerItem(list: SteerItem[], id: string, text: string, clientMessageId?: string, attachments?: InputAttachmentMeta[]): SteerItem[];
|
|
16
18
|
/**
|
|
17
19
|
* Take everything currently queued and clear the list. Returns the drained
|
|
18
20
|
* entries (in order) and the now-empty remainder. The turn loop calls this at
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
/** Append a steer entry. Blank text is dropped (returns the list unchanged). */
|
|
2
|
-
export function enqueueSteerItem(list, id, text, clientMessageId) {
|
|
2
|
+
export function enqueueSteerItem(list, id, text, clientMessageId, attachments) {
|
|
3
3
|
const t = text?.trim();
|
|
4
4
|
if (!id || !t)
|
|
5
5
|
return list;
|
|
6
|
-
return [
|
|
6
|
+
return [
|
|
7
|
+
...list,
|
|
8
|
+
{
|
|
9
|
+
id,
|
|
10
|
+
text: t,
|
|
11
|
+
...(clientMessageId ? { clientMessageId } : {}),
|
|
12
|
+
...(attachments && attachments.length > 0 ? { attachments: [...attachments] } : {}),
|
|
13
|
+
},
|
|
14
|
+
];
|
|
7
15
|
}
|
|
8
16
|
/**
|
|
9
17
|
* Take everything currently queued and clear the list. Returns the drained
|