@cjhyy/code-shell-core 0.7.0-beta.1 → 0.7.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.
Files changed (57) hide show
  1. package/dist/cc-orchestrator/agent-adapter.d.ts +2 -0
  2. package/dist/cc-orchestrator/agent-adapter.js +4 -0
  3. package/dist/cc-orchestrator/codex-session-history.d.ts +14 -1
  4. package/dist/cc-orchestrator/codex-session-history.js +64 -4
  5. package/dist/cc-orchestrator/external-agent-changes.js +22 -5
  6. package/dist/cc-orchestrator/external-agent-driver.d.ts +1 -1
  7. package/dist/cc-orchestrator/external-agent-driver.js +202 -38
  8. package/dist/cc-orchestrator/session-history.d.ts +35 -0
  9. package/dist/cc-orchestrator/session-history.js +96 -13
  10. package/dist/credentials/access.d.ts +1 -0
  11. package/dist/credentials/access.js +2 -0
  12. package/dist/credentials/index.d.ts +2 -1
  13. package/dist/credentials/index.js +1 -0
  14. package/dist/credentials/oauth.d.ts +20 -0
  15. package/dist/credentials/oauth.js +114 -0
  16. package/dist/credentials/store.d.ts +1 -0
  17. package/dist/credentials/store.js +3 -1
  18. package/dist/credentials/types.d.ts +47 -1
  19. package/dist/engine/engine.d.ts +6 -2
  20. package/dist/engine/engine.js +159 -192
  21. package/dist/engine/goal.d.ts +17 -0
  22. package/dist/engine/goal.js +16 -6
  23. package/dist/engine/input-attachments.js +156 -13
  24. package/dist/engine/run-image-input.d.ts +22 -0
  25. package/dist/engine/run-image-input.js +195 -0
  26. package/dist/engine/steer-queue.d.ts +3 -1
  27. package/dist/engine/steer-queue.js +10 -2
  28. package/dist/engine/turn-loop.d.ts +30 -1
  29. package/dist/engine/turn-loop.js +112 -17
  30. package/dist/hooks/goal-stop-hook.d.ts +33 -1
  31. package/dist/hooks/goal-stop-hook.js +202 -34
  32. package/dist/index.d.ts +2 -2
  33. package/dist/index.js +2 -2
  34. package/dist/preset/index.js +14 -4
  35. package/dist/protocol/server.js +1 -1
  36. package/dist/protocol/types.d.ts +2 -0
  37. package/dist/session/session-manager.js +34 -1
  38. package/dist/tool-system/builtin/agent-notifications.d.ts +11 -4
  39. package/dist/tool-system/builtin/agent-notifications.js +19 -7
  40. package/dist/tool-system/builtin/background-jobs.d.ts +25 -5
  41. package/dist/tool-system/builtin/background-jobs.js +105 -7
  42. package/dist/tool-system/builtin/cron-list.definition.d.ts +3 -0
  43. package/dist/tool-system/builtin/cron-list.definition.js +6 -0
  44. package/dist/tool-system/builtin/cron.d.ts +1 -2
  45. package/dist/tool-system/builtin/cron.js +9 -7
  46. package/dist/tool-system/builtin/drive-claude-code.d.ts +7 -0
  47. package/dist/tool-system/builtin/drive-claude-code.js +307 -20
  48. package/dist/tool-system/builtin/index.js +15 -3
  49. package/dist/tool-system/builtin/sleep.d.ts +1 -2
  50. package/dist/tool-system/builtin/sleep.definition.d.ts +8 -0
  51. package/dist/tool-system/builtin/sleep.definition.js +28 -0
  52. package/dist/tool-system/builtin/sleep.js +1 -22
  53. package/dist/tool-system/context.d.ts +18 -0
  54. package/dist/tool-system/mcp-manager.d.ts +14 -2
  55. package/dist/tool-system/mcp-manager.js +56 -7
  56. package/dist/types.d.ts +23 -7
  57. package/package.json +1 -1
@@ -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
- const displayPath = attachment.path || attachment.relPath || attachment.absPath;
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 ${attachment.id || "(unknown)"} has no path`);
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 ${attachment.id || displayPath} session mismatch: expected ${expectedSessionId}, got ${attachment.sessionId}`);
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 ${attachment.id || displayPath} stat failed: ${err.message}`);
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 ${attachment.id || displayPath} blocked by path policy: ${policy.reason}`);
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 ${attachment.id || displayPath} is not a regular file or directory`);
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 = attachment.mime || IMAGE_MIME_BY_EXT[extname(realPath).toLowerCase()];
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 ${attachment.id || displayPath} has unsupported image type`);
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 candidate = attachment.vision?.mediaPath || attachment.absPath || attachment.relPath || attachment.path;
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
+ }
@@ -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 [...list, { id, text: t, ...(clientMessageId ? { clientMessageId } : {}) }];
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
@@ -10,9 +10,10 @@ import { ModelFacade } from "./model-facade.js";
10
10
  import { ToolExecutor } from "../tool-system/executor.js";
11
11
  import { ContextManager } from "../context/manager.js";
12
12
  import { HookRegistry } from "../hooks/registry.js";
13
+ import { type GoalJudgeRuntimeContext } from "../hooks/goal-stop-hook.js";
13
14
  import { Transcript } from "../session/transcript.js";
14
15
  import { type CumulativeUsageCounters } from "./session-usage.js";
15
- import { type GoalConfig, type GoalExtension } from "./goal.js";
16
+ import { type GoalConfig, type GoalExtension, type GoalTerminationReason } from "./goal.js";
16
17
  export interface TurnLoopConfig {
17
18
  maxTurns: number;
18
19
  maxToolCallsPerTurn: number;
@@ -25,6 +26,11 @@ export interface TurnLoopConfig {
25
26
  * response, then downgrades them to placeholders in the working history.
26
27
  */
27
28
  freshImageMessages?: Iterable<Message>;
29
+ /**
30
+ * Per-run injected context that must be visible to the model but must not be
31
+ * summarized into durable history. Engine uses this for dynamicContext.
32
+ */
33
+ volatileContextMessages?: Iterable<Message>;
28
34
  /**
29
35
  * Fired after each turn boundary is recorded. Lets the engine flush an
30
36
  * up-to-date snapshot to state.json mid-run, so a long run doesn't leave
@@ -88,6 +94,11 @@ export interface TurnLoopDeps {
88
94
  * Returns the updated counters so usage_update can carry both metric scopes.
89
95
  */
90
96
  recordCumulativeUsage?: (usage: TokenUsage) => CumulativeUsageCounters;
97
+ /**
98
+ * Lightweight per-session prompt-cache diagnostic, owned by Engine because it
99
+ * needs memory across TurnLoop instances.
100
+ */
101
+ recordCacheReadDiagnostics?: (usage: TokenUsage) => void;
91
102
  /** Persist the latest context-estimation anchor derived from provider usage. */
92
103
  recordContextUsageAnchor?: (anchor: ContextUsageAnchor) => void;
93
104
  /**
@@ -99,12 +110,20 @@ export interface TurnLoopDeps {
99
110
  * tests (turn loop tolerates undefined).
100
111
  */
101
112
  consumeSteer?: (source?: "normal_step" | "finalize_backfill") => SteerItem[];
113
+ /** Restore steer items whose model-facing content could not be prepared. */
114
+ restoreSteer?: (items: SteerItem[]) => void;
115
+ /** Build model-facing content for a queued steer item, including attachments. */
116
+ buildSteerUserMessageContent?: (item: SteerItem) => string | ContentBlock[] | Promise<string | ContentBlock[]>;
102
117
  /**
103
118
  * Execution-level idempotency guard for host-supplied user/steer intents.
104
119
  * Returns false only when a present clientMessageId has already entered this
105
120
  * run or the persisted transcript. Messages without an id bypass this guard.
106
121
  */
107
122
  claimClientMessageId?: (clientMessageId: string, source: "steer") => boolean;
123
+ /** Release a steer id reservation when preparation fails before persistence. */
124
+ releaseClientMessageId?: (clientMessageId: string) => void;
125
+ /** Make tools launched after an injected steer attribute side effects to that steer. */
126
+ setOriginClientMessageId?: (clientMessageId: string | undefined) => void;
108
127
  /**
109
128
  * Clear this session's PERSISTED goal (state.activeGoal) and drop the
110
129
  * in-flight goal-stop hook, so a user-initiated cancel_goal both stops the
@@ -114,11 +133,18 @@ export interface TurnLoopDeps {
114
133
  * goalTracker short-circuit still stops the run).
115
134
  */
116
135
  clearPersistedGoal?: () => void;
136
+ /**
137
+ * Private Goal-judge evidence seam. Engine stores this snapshot in the
138
+ * built-in judge closure; it is never added to the public on_stop context.
139
+ */
140
+ updateGoalJudgeContext?: (context: GoalJudgeRuntimeContext) => void;
117
141
  }
118
142
  export interface TurnLoopResult {
119
143
  text: string;
120
144
  reason: TerminalReason;
121
145
  messages: Message[];
146
+ /** Goal-specific forced terminal outcome; public TerminalReason stays stable. */
147
+ goalTermination?: GoalTerminationReason;
122
148
  }
123
149
  /**
124
150
  * 把一个 ToolResult 映射成发给 LLM 的 tool_result ContentBlock。
@@ -145,6 +171,7 @@ export declare class TurnLoop {
145
171
  private currentCumulativeUsage;
146
172
  private readonly sensitiveToolResultRedactions;
147
173
  private readonly pendingImageMessages;
174
+ private readonly volatileContextMessages;
148
175
  /**
149
176
  * Consecutive on_stop blocks (Goal mode kept the agent going). Reset to 0
150
177
  * on any unblocked completion. When it reaches config.maxStopBlocks the
@@ -203,6 +230,8 @@ export declare class TurnLoop {
203
230
  */
204
231
  private markStopped;
205
232
  private prepareMessagesForModel;
233
+ private stripVolatileContextMessages;
234
+ private appendVolatileContextMessages;
206
235
  private markPendingImagesConsumed;
207
236
  private redactConsumedSensitiveToolResults;
208
237
  private modelCallRecordingOptions;