chainlesschain 0.166.131 → 0.166.133

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 (32) hide show
  1. package/README.md +46 -18
  2. package/package.json +1 -1
  3. package/src/assets/linux-subreaper/linux-arm64/manifest.json +1 -1
  4. package/src/assets/linux-subreaper/linux-x64/manifest.json +1 -1
  5. package/src/data/changelog.json +26 -0
  6. package/src/gateways/ws/llm-chat-protocol.js +1 -1
  7. package/src/gateways/ws/personal-data-hub-protocol.js +27 -11
  8. package/src/gateways/ws/session-core-protocol.js +22 -17
  9. package/src/gateways/ws/session-protocol.js +19 -8
  10. package/src/gateways/ws/ws-agent-handler.js +30 -0
  11. package/src/lib/chat-core.js +40 -35
  12. package/src/lib/context-memory-kernel/message-adapter.js +27 -6
  13. package/src/lib/context-memory-kernel/provider-context.js +48 -3
  14. package/src/lib/evolution/agent-model-projection.js +0 -1
  15. package/src/lib/evolution/governed-hub-llm.js +5 -1
  16. package/src/lib/evolution/governed-hub-resolver.js +15 -6
  17. package/src/lib/evolution/governed-hub-skill.js +2 -0
  18. package/src/lib/evolution/optional-ui-model-turn.js +13 -2
  19. package/src/lib/llm-config-defaults.js +17 -5
  20. package/src/lib/llm-connection-config.js +10 -2
  21. package/src/lib/llm-connection-probe.js +5 -2
  22. package/src/lib/llm-connection-resolution.js +7 -4
  23. package/src/lib/llm-providers.js +24 -11
  24. package/src/lib/openai-responses.js +2 -1
  25. package/src/lib/personal-data-hub-wiring.js +69 -36
  26. package/src/lib/provider-protocol.js +35 -3
  27. package/src/lib/provider-stream.js +6 -4
  28. package/src/lib/runnable-provider.js +4 -0
  29. package/src/lib/ws-chat-handler.js +22 -9
  30. package/src/runtime/agent-core.js +201 -15
  31. package/src/runtime/message-roles.js +67 -3
  32. package/src/runtime/provider-stream-state.js +35 -1
@@ -1,3 +1,4 @@
1
+ import { isDeepStrictEqual } from "node:util";
1
2
  import {
2
3
  canonicalDigest,
3
4
  canonicalJson,
@@ -205,6 +206,17 @@ export function messagesToContextItems(messagesInput, options = {}) {
205
206
  ? [...options.allowedSinks]
206
207
  : DEFAULT_ALLOWED_SINKS;
207
208
  const items = [];
209
+ const requiredUser = options.requiredUserMessage;
210
+ if (
211
+ requiredUser != null &&
212
+ (!Number.isSafeInteger(requiredUser.index) ||
213
+ requiredUser.index < 0 ||
214
+ requiredUser.index >= messages.length ||
215
+ requiredUser.message?.role !== "user" ||
216
+ !isDeepStrictEqual(messages[requiredUser.index], requiredUser.message))
217
+ ) {
218
+ throw new TypeError("Invalid current-user task binding");
219
+ }
208
220
  const trustedSystemIndexes = new Set(
209
221
  Array.isArray(options.trustedSystemIndexes)
210
222
  ? options.trustedSystemIndexes.filter(Number.isSafeInteger)
@@ -289,11 +301,20 @@ export function messagesToContextItems(messagesInput, options = {}) {
289
301
  const lastIndex = items.findLastIndex(
290
302
  (item) => item.kind !== "system-policy",
291
303
  );
292
- const last = items[lastIndex];
293
- if (last) {
294
- const payload = JSON.parse(loadBundleContent(last, sessionId));
295
- if (payload.messages.at(-1)?.role === "user") {
296
- const protectedItem = { ...last };
304
+ for (let index = 0; index < items.length; index++) {
305
+ const item = items[index];
306
+ if (
307
+ index !== lastIndex &&
308
+ item.sourceRef.eventSequence !== requiredUser?.index
309
+ )
310
+ continue;
311
+ const payload = JSON.parse(loadBundleContent(item, sessionId));
312
+ if (
313
+ payload.messages.at(-1)?.role === "user" &&
314
+ (index === lastIndex ||
315
+ item.sourceRef.eventSequence === requiredUser?.index)
316
+ ) {
317
+ const protectedItem = { ...item };
297
318
  delete protectedItem.digest;
298
319
  protectedItem.kind = "task-state";
299
320
  protectedItem.priority = 900_000;
@@ -301,7 +322,7 @@ export function messagesToContextItems(messagesInput, options = {}) {
301
322
  taskState: "waiting",
302
323
  requiredForRecovery: true,
303
324
  };
304
- items[lastIndex] = normalizeContextItem(protectedItem);
325
+ items[index] = normalizeContextItem(protectedItem);
305
326
  }
306
327
  }
307
328
  return items;
@@ -1,3 +1,4 @@
1
+ import { isDeepStrictEqual } from "node:util";
1
2
  import {
2
3
  canonicalDigest,
3
4
  canonicalJson,
@@ -203,6 +204,40 @@ export async function prepareCanonicalProviderContext(
203
204
  };
204
205
  }
205
206
 
207
+ // Resolve against the final provider projection, never a pre-projection index.
208
+ // Exact role/content/metadata matching prevents a transformed tool result or
209
+ // an ambiguous duplicate from taking the original user's task-state binding.
210
+ const taskBinding = options.contextMemoryRequiredUserBinding;
211
+ const requiredUserMessage = taskBinding?.message;
212
+ function resolveRequiredUserIndex() {
213
+ if (requiredUserMessage == null) return null;
214
+ const identities = messages.flatMap((message, index) =>
215
+ message === taskBinding.original ? [index] : [],
216
+ );
217
+ const candidates = identities.length
218
+ ? identities
219
+ : taskBinding.allowContentRelocation === true
220
+ ? messages.map((_, index) => index)
221
+ : [];
222
+ const matches = candidates.filter((index) => {
223
+ const message = messages[index];
224
+ return (
225
+ message?.role === "user" &&
226
+ requiredUserMessage.role === "user" &&
227
+ isDeepStrictEqual(message, requiredUserMessage)
228
+ );
229
+ });
230
+ if (matches.length !== 1 || identities.length > 1) {
231
+ const error = new Error(
232
+ "The current user task was lost, changed or duplicated in provider context",
233
+ );
234
+ error.code = "CC_AGENT_TASK_CONTEXT_LOST";
235
+ throw error;
236
+ }
237
+ return matches[0];
238
+ }
239
+ let requiredUserIndex = resolveRequiredUserIndex();
240
+
206
241
  // Review can exclude recalled author/session data while retaining the exact
207
242
  // canonical context, tool and output-token planning boundary.
208
243
  const recallEnabled = options.contextMemoryRecall !== false;
@@ -211,9 +246,9 @@ export async function prepareCanonicalProviderContext(
211
246
  : null;
212
247
  const sink = String(options.contextMemorySink || `provider.${provider}`);
213
248
  const scopeAdmissions = defaultAdmissions(sessionId, options);
214
- const lastUser = [...messages]
215
- .reverse()
216
- .find((message) => message?.role === "user");
249
+ const lastUser =
250
+ requiredUserMessage ||
251
+ [...messages].reverse().find((message) => message?.role === "user");
217
252
  const query =
218
253
  typeof lastUser?.content === "string" && lastUser.content.trim()
219
254
  ? lastUser.content.trim().slice(0, 32 * 1024)
@@ -254,6 +289,9 @@ export async function prepareCanonicalProviderContext(
254
289
  }),
255
290
  };
256
291
  options.signal?.throwIfAborted();
292
+ // Recall awaits host and storage work. Revalidate the captured task and its
293
+ // current position before image transformation and immutable item creation.
294
+ requiredUserIndex = resolveRequiredUserIndex();
257
295
  const imageTransport = captureContextImageTransport(messages);
258
296
  const messageItems = adjustContextImageTokenEstimates(
259
297
  messagesToContextItems(imageTransport.messages, {
@@ -261,6 +299,13 @@ export async function prepareCanonicalProviderContext(
261
299
  allowedSinks: [sink],
262
300
  trustCurrentSystemPolicy: true,
263
301
  trustedSystemIndexes: options.contextMemoryTrustedSystemIndexes,
302
+ requiredUserMessage:
303
+ requiredUserIndex === null
304
+ ? null
305
+ : {
306
+ index: requiredUserIndex,
307
+ message: imageTransport.messages[requiredUserIndex],
308
+ },
264
309
  allowExternalization: false,
265
310
  }),
266
311
  imageTransport.captures,
@@ -221,7 +221,6 @@ function openAIReasoningItem(value) {
221
221
  if (Object.hasOwn(value, "summary")) {
222
222
  if (
223
223
  !Array.isArray(value.summary) ||
224
- value.summary.length < 1 ||
225
224
  value.summary.length > 16 ||
226
225
  value.summary.some(
227
226
  (part) =>
@@ -264,7 +264,10 @@ export function createGovernedHubLlm(llm, factory, options = {}, sdkPorts) {
264
264
  "A Hub model client with declared locality is required",
265
265
  );
266
266
  }
267
- if (typeof factory !== "function")
267
+ if (
268
+ typeof factory !== "function" &&
269
+ !(factory === null && options.evolutionDisabled === true)
270
+ )
268
271
  throw new TypeError("A Hub evolution factory is required");
269
272
  if (options.onFailure != null && typeof options.onFailure !== "function")
270
273
  throw new TypeError("onFailure must be a function");
@@ -298,6 +301,7 @@ export function createGovernedHubLlm(llm, factory, options = {}, sdkPorts) {
298
301
  mode: "hub-analysis",
299
302
  messages,
300
303
  signal: chatOptions.signal,
304
+ evolutionDisabled: options.evolutionDisabled === true,
301
305
  });
302
306
  },
303
307
  async transport(messages, chatOptions = {}) {
@@ -77,8 +77,16 @@ function captureEmbeddingTransport(stage, sdk) {
77
77
  };
78
78
  }
79
79
 
80
- export function createGovernedHubResolver(ports, factory, sdkPorts) {
81
- if (typeof factory !== "function")
80
+ export function createGovernedHubResolver(
81
+ ports,
82
+ factory,
83
+ sdkPorts,
84
+ options = {},
85
+ ) {
86
+ if (
87
+ typeof factory !== "function" &&
88
+ !(factory === null && options.evolutionDisabled === true)
89
+ )
82
90
  throw new TypeError("A resolver evolution factory is required");
83
91
  const {
84
92
  resolver,
@@ -102,13 +110,14 @@ export function createGovernedHubResolver(ports, factory, sdkPorts) {
102
110
  const turn = await prepareGovernedModelTurn(factory, {
103
111
  mode: "hub-embedding",
104
112
  messages: [{ role: "user", content: profile }],
113
+ evolutionDisabled: options.evolutionDisabled === true,
105
114
  });
106
115
  // Chat projection prepends host provenance. Embed only the projected
107
116
  // profile: per-Run evidence IDs must not change semantic similarity.
108
- const profileMessage = turn.messages[1];
117
+ const profileMessage = turn.messages[turn.governed ? 1 : 0];
109
118
  if (
110
- turn.messages.length !== 2 ||
111
- turn.messages[0].role !== "system" ||
119
+ turn.messages.length !== (turn.governed ? 2 : 1) ||
120
+ (turn.governed && turn.messages[0].role !== "system") ||
112
121
  profileMessage?.role !== "user" ||
113
122
  typeof profileMessage.content !== "string"
114
123
  )
@@ -133,7 +142,7 @@ export function createGovernedHubResolver(ports, factory, sdkPorts) {
133
142
  const model =
134
143
  resolver._llmStage &&
135
144
  new LLMStage({
136
- llm: createGovernedHubLlm(llm, factory, {}, sdk),
145
+ llm: createGovernedHubLlm(llm, factory, options, sdk),
137
146
  acceptNonLocal: false,
138
147
  });
139
148
  return new EntityResolver({
@@ -9,6 +9,7 @@ export async function runGovernedHubSkill(
9
9
  name,
10
10
  options,
11
11
  sdkPorts,
12
+ modelTurnOptions = {},
12
13
  ) {
13
14
  let failure;
14
15
  let failed = false;
@@ -16,6 +17,7 @@ export async function runGovernedHubSkill(
16
17
  hub.llm,
17
18
  factory,
18
19
  {
20
+ evolutionDisabled: modelTurnOptions.evolutionDisabled === true,
19
21
  onFailure(error) {
20
22
  failure = error;
21
23
  failed = true;
@@ -12,9 +12,20 @@ export async function loadConfiguredUiEvolutionFactory(options = {}) {
12
12
  }
13
13
 
14
14
  export async function resolveServerEvolutionFactory(server) {
15
- return typeof server.resolveEvolutionCompositionFactory === "function"
15
+ return typeof server?.resolveEvolutionCompositionFactory === "function"
16
16
  ? server.resolveEvolutionCompositionFactory()
17
- : (server.evolutionCompositionFactory ?? null);
17
+ : (server?.evolutionCompositionFactory ?? null);
18
+ }
19
+
20
+ /** Apply the host's current UI policy for every turn, including old sessions. */
21
+ export async function prepareServerUiModelTurn(server, context) {
22
+ if (typeof server?.prepareUiModelTurn === "function") {
23
+ return server.prepareUiModelTurn(context);
24
+ }
25
+ return prepareGovernedModelTurn(
26
+ await resolveServerEvolutionFactory(server),
27
+ context,
28
+ );
18
29
  }
19
30
 
20
31
  export async function prepareConfiguredUiModelTurn(context, options = {}) {
@@ -35,7 +35,12 @@
35
35
  * so an intentionally-custom endpoint is left untouched.
36
36
  */
37
37
  export async function reconcileConfigLlmProvider(cfgLlm = {}) {
38
- if (!cfgLlm || !cfgLlm.provider || !cfgLlm.baseUrl) {
38
+ if (
39
+ !cfgLlm ||
40
+ !cfgLlm.provider ||
41
+ !cfgLlm.baseUrl ||
42
+ cfgLlm.provider === "custom"
43
+ ) {
39
44
  return { llm: cfgLlm, changed: false };
40
45
  }
41
46
  const { inferProviderFromBaseUrl, modelForeignToProvider } = await import(
@@ -71,22 +76,29 @@ export function applyConfigLlmDefaults(options = {}, cfgLlm = {}, opts = {}) {
71
76
  // credentials are exactly right. Mixing config into a DIFFERENT explicit
72
77
  // provider is still avoided (handled by the early return below).
73
78
  if (options.provider && configMatchesTarget) {
74
- if (!options.baseUrl && cfgLlm.baseUrl) options.baseUrl = connection.baseUrl;
79
+ if (!options.baseUrl && cfgLlm.baseUrl)
80
+ options.baseUrl = connection.baseUrl;
75
81
  if (options.serviceTier == null && cfgLlm.serviceTier != null)
76
82
  options.serviceTier = cfgLlm.serviceTier;
77
- if (!options.apiKey && connection.apiKey) options.apiKey = connection.apiKey;
83
+ if (!options.apiKey && connection.apiKey)
84
+ options.apiKey = connection.apiKey;
78
85
  if (!options.model && !opts.explicitModel && cfgLlm.model) {
79
86
  options.model = cfgLlm.model;
80
87
  }
81
88
  return options;
82
89
  }
83
90
  if (options.provider) {
84
- if (!options.apiKey && connection.apiKey) options.apiKey = connection.apiKey;
91
+ if (!options.apiKey && connection.apiKey)
92
+ options.apiKey = connection.apiKey;
85
93
  return options;
86
94
  }
87
95
  if (!cfgLlm.provider) return options;
88
96
  options.provider = connection.provider;
89
- if (configMatchesTarget && options.serviceTier == null && cfgLlm.serviceTier != null)
97
+ if (
98
+ configMatchesTarget &&
99
+ options.serviceTier == null &&
100
+ cfgLlm.serviceTier != null
101
+ )
90
102
  options.serviceTier = cfgLlm.serviceTier;
91
103
  if (opts.explicitModel) {
92
104
  options.model = opts.explicitModel;
@@ -31,7 +31,10 @@ export function normalizeLlmConnection(input) {
31
31
  if (value[key].length > (key === "apiKey" ? 8192 : 2048))
32
32
  throw new Error(`${key} is too long`);
33
33
  }
34
- if (!Object.hasOwn(BUILT_IN_PROVIDERS, value.provider))
34
+ if (
35
+ value.provider !== "custom" &&
36
+ !Object.hasOwn(BUILT_IN_PROVIDERS, value.provider)
37
+ )
35
38
  throw new Error("Choose a supported provider/protocol");
36
39
  if (!value.model || /[\r\n\0]/.test(value.model + value.visionModel))
37
40
  throw new Error("A valid model name is required");
@@ -84,7 +87,7 @@ export function saveLlmConnection(input, { storage = "auto" } = {}) {
84
87
  current.baseUrl || BUILT_IN_PROVIDERS[current.provider]?.baseUrl || "",
85
88
  ).replace(/\/+$/, "") === value.baseUrl;
86
89
  if (
87
- value.provider !== "ollama" &&
90
+ !["ollama", "custom"].includes(value.provider) &&
88
91
  !value.apiKey &&
89
92
  (!sameEndpoint || !current.apiKey)
90
93
  )
@@ -98,6 +101,11 @@ export function saveLlmConnection(input, { storage = "auto" } = {}) {
98
101
  baseUrl: value.baseUrl,
99
102
  visionModel: value.visionModel || null,
100
103
  };
104
+ // A keyless custom endpoint must never receive the previous target's key.
105
+ if (!sameEndpoint && !value.apiKey && value.provider === "custom") {
106
+ delete config.llm.apiKey;
107
+ delete config.llm.apiKeyHelper;
108
+ }
101
109
  };
102
110
  // A single lock and atomic rename bind endpoint and credential together.
103
111
  // An interrupted save cannot route the old credential to the new endpoint.
@@ -3,6 +3,7 @@ import { estimateCost } from "./llm-pricing.js";
3
3
  import { providerFetch } from "./formal-budget-provider-scope.js";
4
4
  import { isFormalBudgetError } from "./formal-budget-errors.js";
5
5
  import { normalizeOllamaBaseUrl } from "./ollama-base-url.js";
6
+ import { assertCustomProviderConnection } from "./provider-protocol.js";
6
7
  import { resolveModelCapabilityProfile } from "./model-capabilities.js";
7
8
  import {
8
9
  projectEnvelopePricingContext,
@@ -159,6 +160,7 @@ export async function probeLlmConnection(
159
160
  target,
160
161
  { fetchImpl = fetch, timeoutMs = 20_000, includeUsage = false } = {},
161
162
  ) {
163
+ assertCustomProviderConnection(target.provider, target);
162
164
  const base =
163
165
  target.provider === "ollama"
164
166
  ? normalizeOllamaBaseUrl(target.baseUrl)
@@ -183,7 +185,8 @@ export async function probeLlmConnection(
183
185
  options: { num_predict: PROBE_OUTPUT_TOKENS },
184
186
  };
185
187
  } else {
186
- if (!target.apiKey) throw new Error("API key required");
188
+ if (!target.apiKey && target.provider !== "custom")
189
+ throw new Error("API key required");
187
190
  if (target.provider === "anthropic") {
188
191
  url = `${base}/messages`;
189
192
  headers["x-api-key"] = target.apiKey;
@@ -211,7 +214,7 @@ export async function probeLlmConnection(
211
214
  });
212
215
  } else {
213
216
  url = `${base}/chat/completions`;
214
- headers.Authorization = `Bearer ${target.apiKey}`;
217
+ if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
215
218
  body = {
216
219
  model: target.model,
217
220
  messages: [{ role: "user", content: "Say hi in one word." }],
@@ -1,6 +1,9 @@
1
1
  import { BUILT_IN_PROVIDERS } from "./llm-providers.js";
2
2
  import { normalizeOllamaBaseUrl } from "./ollama-base-url.js";
3
- import { normalizeProviderBaseUrl } from "./provider-protocol.js";
3
+ import {
4
+ assertCustomProviderConnection,
5
+ normalizeProviderBaseUrl,
6
+ } from "./provider-protocol.js";
4
7
  import { resolveApiKeyFromHelper } from "./api-key-helper.js";
5
8
 
6
9
  const DEFAULT_PROVIDER = "ollama";
@@ -33,9 +36,8 @@ export function resolveLlmConnection(
33
36
  defaultBaseUrl = null,
34
37
  } = {},
35
38
  ) {
36
- const sources = (Array.isArray(configuredSources)
37
- ? configuredSources
38
- : [configuredSources]
39
+ const sources = (
40
+ Array.isArray(configuredSources) ? configuredSources : [configuredSources]
39
41
  ).filter((source) => source && typeof source === "object");
40
42
  const provider =
41
43
  (nonEmpty(options.provider) && options.provider) ||
@@ -67,6 +69,7 @@ export function resolveLlmConnection(
67
69
  defaultModel ||
68
70
  definition?.models?.[0] ||
69
71
  null;
72
+ assertCustomProviderConnection(provider, { baseUrl, model });
70
73
  let apiKey =
71
74
  (nonEmpty(options.apiKey) && options.apiKey) ||
72
75
  (nonEmpty(env?.CC_API_KEY) && env.CC_API_KEY) ||
@@ -127,6 +127,19 @@ export const BUILT_IN_PROVIDERS = {
127
127
  },
128
128
  };
129
129
 
130
+ /** Custom platforms use Chat Completions with an explicit endpoint and model. */
131
+ export function resolveProviderDefinition(provider) {
132
+ return provider === "custom"
133
+ ? {
134
+ name: "custom",
135
+ baseUrl: null,
136
+ models: [],
137
+ apiKeyEnv: "CC_API_KEY",
138
+ custom: true,
139
+ }
140
+ : BUILT_IN_PROVIDERS[provider];
141
+ }
142
+
130
143
  // A connectivity probe (`testProvider`) sends a tiny "Hi" and expects a quick
131
144
  // reply, so it must fail fast — a probe that hangs forever defeats its purpose
132
145
  // (it backs `cc llm test`, provider selection, doctor). Bound it with a total
@@ -402,17 +415,17 @@ export class LLMProviderRegistry {
402
415
  const res = await _fetchWithTimeout(
403
416
  `${normalizeProviderBaseUrl(provider.baseUrl)}/messages`,
404
417
  {
405
- method: "POST",
406
- headers: {
407
- "Content-Type": "application/json",
408
- "x-api-key": key,
409
- "anthropic-version": "2023-06-01",
410
- },
411
- body: JSON.stringify({
412
- model: testModel,
413
- max_tokens: 10,
414
- messages: [{ role: "user", content: "Hi" }],
415
- }),
418
+ method: "POST",
419
+ headers: {
420
+ "Content-Type": "application/json",
421
+ "x-api-key": key,
422
+ "anthropic-version": "2023-06-01",
423
+ },
424
+ body: JSON.stringify({
425
+ model: testModel,
426
+ max_tokens: 10,
427
+ messages: [{ role: "user", content: "Hi" }],
428
+ }),
416
429
  },
417
430
  );
418
431
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -103,7 +103,8 @@ function sanitizeReasoningItems(items) {
103
103
  type: "reasoning",
104
104
  ...(id ? { id } : {}),
105
105
  ...(encryptedContent ? { encrypted_content: encryptedContent } : {}),
106
- ...(summary.length ? { summary } : {}),
106
+ // Responses requires this field even when no summary was requested.
107
+ summary,
107
108
  ...(status ? { status } : {}),
108
109
  });
109
110
  }