@cubicecho/agent-core 4.1.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -588,7 +588,7 @@ one just over it means the ranking chose arbitrarily, which is the case a model
588
588
  on. Matching nothing is not confident either, since the words cannot tell a request that needs no
589
589
  tools from one whose words are not in the catalogue. An empty `toolSelectModel` still means no
590
590
  preselection at all, words included. `preselect` also takes a `temperature` and a
591
- `reasoningEffort`, read as `ask` reads them, and `runAgentLoop({ preselect: true })` makes the call
591
+ `reasoningEffort` and an `extraBody`, read as `ask` reads them, and `runAgentLoop({ preselect: true })` makes the call
592
592
  itself — see "Its side tasks" below.
593
593
 
594
594
  A turn cut off at `maxTokens` is said so as a notice, and with `maxContinuations` above zero it is
@@ -1165,6 +1165,24 @@ applies `resolveApiKey` afterwards, so a document naming somebody else's endpoin
1165
1165
  host send its own provider key there. `exportSpec(spec)` strips a bundled server's `env` and
1166
1166
  `headers`, and a redacted document is still a valid one.
1167
1167
 
1168
+ Those two fields are where the format puts a bundled server's credentials, and not the only place a
1169
+ pasted `.mcp.json` has them: an `--api-key` among the `args`, a `?token=` or a `user:pass@` in the
1170
+ `url`. `exportSpec` keeps those, since a server with its query string cut off is a different
1171
+ server, and `findSpecSecrets` says where they may be so a host can warn before it calls a file safe
1172
+ to share:
1173
+
1174
+ ```ts
1175
+ const out = exportSpec(spec);
1176
+ for (const { path, server, sign } of findSpecSecrets(out)) {
1177
+ warn(`${server}: ${path} may hold a credential (${sign})`); // bundle.mcpServers[0].args[2] … (flag)
1178
+ }
1179
+ ```
1180
+
1181
+ A hint is a place and a reason — `SecretSign.Userinfo`, `Query`, `Flag`, `Value` or `Field` — and
1182
+ never the value. It is a guess in both directions: a credential in a positional argument with no
1183
+ telling prefix is missed, and a flag called `--keyboard` is named. A `${VAR}` is a reference, and is
1184
+ not reported.
1185
+
1168
1186
  `bundle.mcpServers` is parsed only under `parseSpec(document, { bundle: true })`. A bundled server
1169
1187
  is a command line and an environment map, so importing one is equivalent to running a program, and
1170
1188
  that is a decision a host makes rather than inherits from a default.
@@ -1178,7 +1196,7 @@ comes from.
1178
1196
  ### Its side tasks
1179
1197
 
1180
1198
  `tasks.<key>` configures a side task apart from the main model — its own model, endpoint, ceiling,
1181
- temperature and reasoning effort — and resolution hands each back as a `ResolvedTask` holding only
1199
+ temperature, reasoning effort and `extraBody` — and resolution hands each back as a `ResolvedTask` holding only
1182
1200
  what a layer stated. `taskCall` turns one into the arguments `ask`, `askJson`, `summariser` and
1183
1201
  `preselect` already take, so a setting added to a task later arrives without the host's call
1184
1202
  changing:
@@ -1199,6 +1217,13 @@ no-thinking hints by default, and absent or `"off"` keeps them; a level — `"lo
1199
1217
  sent as `reasoning_effort` in their place, for a compaction model an operator pointed at a model
1200
1218
  that should deliberate.
1201
1219
 
1220
+ `extraBody` carries the sampler fields a model card asks for, as it does on the main model: `ask`,
1221
+ `askJson`, `summariser` and `preselect` all take one in their options, merge it in after the
1222
+ temperature, and offer its keys to `negotiate` as `droppable`, so a field the endpoint refuses by
1223
+ name is latched off for that model and the request is sent again without it. The agent's own
1224
+ `model.extraBody` is never sent to a side task, and `taskCall` lays a task's over the host's key by
1225
+ key.
1226
+
1202
1227
  The third argument is the endpoint the main turn uses. Given it, a task on the same endpoint is
1203
1228
  sent the agent's spelling of the URL and, having no key of its own, the agent's key, so the two
1204
1229
  share one entry in `capabilitiesFor` and a refusal learned by either is known to both; a task on
@@ -1,4 +1,4 @@
1
- import type { EndpointIdentity } from '../core/config.ts';
1
+ import { type EndpointIdentity } from '../core/config.ts';
2
2
  import type { Produced } from '../wire/stream.ts';
3
3
  /**
4
4
  * What an endpoint turned out not to support, and answering it when it says so.
@@ -307,6 +307,20 @@ export declare const ceilingAndTemperature: (refused: ModelCapabilities | undefi
307
307
  } | {
308
308
  temperature?: number | undefined;
309
309
  };
310
+ /**
311
+ * The request fields an `extraBody` adds as this model takes them, to spread into a request body.
312
+ *
313
+ * @param refused - What the model has refused, as `negotiate` hands it over. Absent is a model that
314
+ * has refused nothing.
315
+ * @param [extraBody] - The fields to add. Absent adds none.
316
+ * @returns A fresh object of the fields, less the `RESERVED_BODY_FIELDS` and those the model has
317
+ * refused by name.
318
+ *
319
+ * @remarks
320
+ * The keys are what a caller hands `negotiate` as `droppable`, so a field the endpoint has never
321
+ * heard of is latched into `refusedFields` and left out of the next send.
322
+ */
323
+ export declare const extraFields: (refused: ModelCapabilities | undefined, extraBody?: Record<string, unknown>) => Record<string, unknown>;
310
324
  /** What `negotiate` takes besides the request. All optional. */
311
325
  export interface NegotiateOptions {
312
326
  /**
@@ -1,3 +1,4 @@
1
+ import { RESERVED_BODY_FIELDS } from '../core/config.js';
1
2
  import { getOrCreate } from '../core/guards.js';
2
3
  import { scoped } from '../core/scope.js';
3
4
  import { errorMessage } from '../wire/errors.js';
@@ -353,6 +354,20 @@ export const ceilingAndTemperature = (refused, maxTokens, temperature) => {
353
354
  ...(refused?.chosenTemperature === false ? {} : { temperature }),
354
355
  };
355
356
  };
357
+ /**
358
+ * The request fields an `extraBody` adds as this model takes them, to spread into a request body.
359
+ *
360
+ * @param refused - What the model has refused, as `negotiate` hands it over. Absent is a model that
361
+ * has refused nothing.
362
+ * @param [extraBody] - The fields to add. Absent adds none.
363
+ * @returns A fresh object of the fields, less the `RESERVED_BODY_FIELDS` and those the model has
364
+ * refused by name.
365
+ *
366
+ * @remarks
367
+ * The keys are what a caller hands `negotiate` as `droppable`, so a field the endpoint has never
368
+ * heard of is latched into `refusedFields` and left out of the next send.
369
+ */
370
+ export const extraFields = (refused, extraBody = {}) => Object.fromEntries(Object.entries(extraBody).filter(([field]) => !RESERVED_BODY_FIELDS.includes(field) && !refused?.refusedFields.has(field)));
356
371
  /**
357
372
  * How to answer a refused effort *value*, or `undefined` when there is nothing new to learn or
358
373
  * nowhere left to go — in which case the refusal is the caller's, as it was before this existed.
package/dist/index.d.ts CHANGED
@@ -25,7 +25,7 @@ export { preview } from './run/run-calls.ts';
25
25
  export { resetAll } from './runtime/reset.ts';
26
26
  export { createRuntime, defaultRuntime, type Runtime, type RuntimeOptions } from './runtime/runtime.ts';
27
27
  export { CAPABILITY_SNAPSHOT_VERSION, type CapabilitySnapshot, type EndpointSnapshot, exportCapabilities, importCapabilities, type ModelSnapshot, } from './runtime/snapshot.ts';
28
- export { AGENT_SPEC, AGENT_SPEC_VERSION, AGENT_TASKS, type AgentHook, type AgentSpec, type EndpointSpec, type ExportSpecOptions, exportSpec, type ModelSpec, type ParsedSpec, type ParseSpecOptions, type PromptPart, parseSpec, REF_FILE, REF_URL, RESOLVED_DEFAULTS, type ResolvedAgent, type ResolvedTask, type RetrySpec, resolveAgentSpec, SPEC_EVENTS, type SpecBundle, type SpecServer, type TaskSpec, type ToolsSpec, } from './spec/spec.ts';
28
+ export { AGENT_SPEC, AGENT_SPEC_VERSION, AGENT_TASKS, type AgentHook, type AgentSpec, type EndpointSpec, type ExportSpecOptions, exportSpec, findSpecSecrets, type ModelSpec, type ParsedSpec, type ParseSpecOptions, type PromptPart, parseSpec, REF_FILE, REF_URL, RESOLVED_DEFAULTS, type ResolvedAgent, type ResolvedTask, type RetrySpec, resolveAgentSpec, type SecretHint, SecretSign, SPEC_EVENTS, type SpecBundle, type SpecServer, type TaskSpec, type ToolsSpec, } from './spec/spec.ts';
29
29
  export type { CatalogServer } from './tools/catalog.ts';
30
30
  export { type KeywordPreselection, type KeywordPreselectOptions, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselect, preselectByKeywords, preselectInput, preselection, preselectSystem, type ToolMatch, } from './tools/preselect.ts';
31
31
  export { CALL_TOOL, carryOver, catalogList, catalogPrompt, expandNames, holdsDefinitions, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, orderTools, PROXY_TOOLS, proxiedCall, proxyCatalogPrompt, proxyLoadResult, requestedNames, shownCall, type ToolOrder, } from './tools/tool-loading.ts';
package/dist/index.js CHANGED
@@ -25,7 +25,7 @@ export { preview } from './run/run-calls.js';
25
25
  export { resetAll } from './runtime/reset.js';
26
26
  export { createRuntime, defaultRuntime } from './runtime/runtime.js';
27
27
  export { CAPABILITY_SNAPSHOT_VERSION, exportCapabilities, importCapabilities, } from './runtime/snapshot.js';
28
- export { AGENT_SPEC, AGENT_SPEC_VERSION, AGENT_TASKS, exportSpec, parseSpec, REF_FILE, REF_URL, RESOLVED_DEFAULTS, resolveAgentSpec, SPEC_EVENTS, } from './spec/spec.js';
28
+ export { AGENT_SPEC, AGENT_SPEC_VERSION, AGENT_TASKS, exportSpec, findSpecSecrets, parseSpec, REF_FILE, REF_URL, RESOLVED_DEFAULTS, resolveAgentSpec, SecretSign, SPEC_EVENTS, } from './spec/spec.js';
29
29
  export { PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselect, preselectByKeywords, preselectInput, preselection, preselectSystem, } from './tools/preselect.js';
30
30
  export { CALL_TOOL, carryOver, catalogList, catalogPrompt, expandNames, holdsDefinitions, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, orderTools, PROXY_TOOLS, proxiedCall, proxyCatalogPrompt, proxyLoadResult, requestedNames, shownCall, } from './tools/tool-loading.js';
31
31
  export { continueTurn, } from './turn/continuation.js';
@@ -1,5 +1,5 @@
1
1
  import type OpenAI from 'openai';
2
- import { type ModelParams } from '../core/config.ts';
2
+ import type { ModelParams } from '../core/config.ts';
3
3
  import { type Capabilities, type ModelCapabilities } from '../endpoint/capabilities.ts';
4
4
  import { type ToolOrder } from '../tools/tool-loading.ts';
5
5
  /**
@@ -1,5 +1,4 @@
1
- import { RESERVED_BODY_FIELDS } from '../core/config.js';
2
- import { ceilingAndTemperature, effortFor, } from '../endpoint/capabilities.js';
1
+ import { ceilingAndTemperature, effortFor, extraFields, } from '../endpoint/capabilities.js';
3
2
  import { relaxTools, sanitizeTools } from '../endpoint/schema-compat.js';
4
3
  import { orderTools } from '../tools/tool-loading.js';
5
4
  /**
@@ -33,12 +32,11 @@ export function buildBody(config, supports, refused, messages, tools = [], order
33
32
  const sorted = orderTools(tools, order);
34
33
  const declared = supports.strictSchemas ? sanitizeTools(sorted) : relaxTools(sanitizeTools(sorted));
35
34
  const effort = effortFor(refused, config.reasoningEffort);
36
- const extra = Object.entries(config.extraBody ?? {}).filter(([field]) => !RESERVED_BODY_FIELDS.includes(field) && !refused?.refusedFields.has(field));
37
35
  return {
38
36
  ...ceilingAndTemperature(refused, config.maxTokens, config.temperature),
39
37
  ...(effort ? { reasoning_effort: effort } : {}),
40
38
  ...(supports.usageInStream ? { stream_options: { include_usage: true } } : {}),
41
- ...Object.fromEntries(extra),
39
+ ...extraFields(refused, config.extraBody),
42
40
  model: config.model,
43
41
  messages,
44
42
  stream: true,
@@ -151,6 +151,8 @@ export interface TaskSpec {
151
151
  * side task is sent by default; see `SideTaskOptions.reasoningEffort`.
152
152
  */
153
153
  reasoningEffort?: string;
154
+ /** Request fields the interface cannot spell, as `model.extraBody` but for this task alone. */
155
+ extraBody?: Record<string, unknown>;
154
156
  }
155
157
  /** A tool call bound to a lifecycle event, run by the host rather than the model. */
156
158
  export interface AgentHook {
@@ -198,7 +200,7 @@ export interface SpecBundle {
198
200
  * One side task, resolved: a model, and the endpoint it is reached through.
199
201
  *
200
202
  * @remarks
201
- * The three settings are present only where a layer stated them, and nothing is filled in from
203
+ * The settings are present only where a layer stated them, and nothing is filled in from
202
204
  * the agent's own model — `taskCall` turns one into a call, and what is absent there is left to
203
205
  * the side task's default.
204
206
  */
@@ -209,6 +211,8 @@ export interface ResolvedTask {
209
211
  maxTokens?: number;
210
212
  temperature?: number;
211
213
  reasoningEffort?: string;
214
+ /** Merged key by key across the layers. `model.extraBody` is the agent's and never reaches it. */
215
+ extraBody?: Record<string, unknown>;
212
216
  }
213
217
  /**
214
218
  * What layered documents resolve to: the flat shape the loop already takes, plus the parts of an
@@ -359,7 +363,47 @@ export interface ExportSpecOptions {
359
363
  * The property worth keeping is that **a redacted spec is still a valid spec** — it parses, it
360
364
  * resolves, and what comes back is the same agent pointed at the same servers, needing only the
361
365
  * credentials the importing host supplies itself. So a bundled server loses its `env` and its
362
- * `headers` and nothing else, and nothing outside `bundle` is touched: the format has no
363
- * credential field at any depth, which is the reason there is nothing else to strip.
366
+ * `headers` and nothing else, and nothing outside `bundle` is touched: those two are the only
367
+ * fields the format gives a credential. A server can still carry one where the format gives it no
368
+ * field — an `--api-key` among its `args`, a token in its `url` — and that is kept, because
369
+ * cutting it would point the agent at a different server. `findSpecSecrets` says where to look.
364
370
  */
365
371
  export declare function exportSpec(spec: AgentSpec, { secrets }?: ExportSpecOptions): AgentSpec;
372
+ /** Why `findSpecSecrets` named a place. */
373
+ export declare const SecretSign: {
374
+ /** A URL with a user or a password before its host. */
375
+ readonly Userinfo: 'userinfo';
376
+ /** A URL query parameter whose name reads as a credential's. */
377
+ readonly Query: 'query';
378
+ /** The value of a command-line flag, or of a header written as `Name: value`, so named. */
379
+ readonly Flag: 'flag';
380
+ /** A value with the shape of a credential: a known key prefix, or `Bearer` and a token. */
381
+ readonly Value: 'value';
382
+ /** A key of the server itself whose name reads as a credential's. */
383
+ readonly Field: 'field';
384
+ };
385
+ /** Any one of the reasons in `SecretSign`. */
386
+ export type SecretSign = (typeof SecretSign)[keyof typeof SecretSign];
387
+ /** One place `findSpecSecrets` thinks a credential may be, and never the credential. */
388
+ export interface SecretHint {
389
+ /** Where, in `parseSpec`'s spelling: `bundle.mcpServers[0].args[2]`. */
390
+ path: string;
391
+ /** The server's slug, or its id where it has no slug. */
392
+ server: string;
393
+ sign: SecretSign;
394
+ }
395
+ /**
396
+ * Where a document's bundled servers may still carry a credential after `exportSpec`.
397
+ *
398
+ * @param spec - A document, exported or not: `env` and `headers` are not reported either way,
399
+ * since `exportSpec` strips them.
400
+ * @returns One hint for each suspect place, in document order, each naming where and why and
401
+ * none holding the value. Empty for a document with no bundle.
402
+ *
403
+ * @remarks
404
+ * A host calls this before it offers an export as safe to share, and warns with what comes back.
405
+ * It reads a server's `url`, its `command` and `args`, and the names of its other keys, and is a
406
+ * guess both ways: a credential in a positional argument with no telling prefix is missed, and a
407
+ * flag called `--keyboard` is named. A `${VAR}` is a reference and is never named.
408
+ */
409
+ export declare function findSpecSecrets(spec: AgentSpec): SecretHint[];
package/dist/spec/spec.js CHANGED
@@ -241,10 +241,6 @@ const parseSampling = (report, path, value) => ({
241
241
  * @param value - What the document held under `model`.
242
242
  * @returns The fields that survived — an empty object where none did — or `undefined` where the
243
243
  * section is absent or is not an object.
244
- *
245
- * @remarks
246
- * `extraBody` comes back as a new object without the `RESERVED_BODY_FIELDS`, each of which is
247
- * warned about. An empty one survives as an empty one.
248
244
  */
249
245
  function parseModel(report, value) {
250
246
  if (absent(value)) {
@@ -253,31 +249,41 @@ function parseModel(report, value) {
253
249
  if (!isRecord(value)) {
254
250
  return void report.drop('model', 'must be an object');
255
251
  }
256
- let extraBody;
257
- if (!absent(value.extraBody)) {
258
- if (!isRecord(value.extraBody)) {
259
- report.drop('model.extraBody', 'must be an object');
260
- }
261
- else {
262
- extraBody = {};
263
- for (const [key, held] of Object.entries(value.extraBody)) {
264
- // `buildBody` would overwrite them anyway; better to say so on import.
265
- if (RESERVED_BODY_FIELDS.includes(key)) {
266
- report.drop(`model.extraBody.${key}`, "is the loop's to set");
267
- }
268
- else {
269
- extraBody[key] = held;
270
- }
271
- }
272
- }
273
- }
274
252
  return kept({
275
253
  model: report.string('model.model', value.model),
276
254
  ...parseSampling(report, 'model', value),
277
255
  contextLength: report.number('model.contextLength', value.contextLength, 0),
278
- extraBody,
256
+ extraBody: parseExtraBody(report, 'model.extraBody', value.extraBody),
279
257
  });
280
258
  }
259
+ /**
260
+ * Reads an `extraBody`, the agent's model's or a side task's.
261
+ *
262
+ * @param report - Where what is dropped is written.
263
+ * @param path - Where it sits in the document: `model.extraBody`, or `tasks.title.extraBody`.
264
+ * @param value - What the document held there.
265
+ * @returns A new object without the `RESERVED_BODY_FIELDS`, each of which is warned about, or
266
+ * `undefined` where it is absent or is not an object. An empty one survives as an empty one.
267
+ */
268
+ function parseExtraBody(report, path, value) {
269
+ if (absent(value)) {
270
+ return undefined;
271
+ }
272
+ if (!isRecord(value)) {
273
+ return void report.drop(path, 'must be an object');
274
+ }
275
+ const extraBody = {};
276
+ for (const [key, held] of Object.entries(value)) {
277
+ // The request is built around them and they would be overwritten anyway; better to say so on import.
278
+ if (RESERVED_BODY_FIELDS.includes(key)) {
279
+ report.drop(`${path}.${key}`, "is the loop's to set");
280
+ }
281
+ else {
282
+ extraBody[key] = held;
283
+ }
284
+ }
285
+ return extraBody;
286
+ }
281
287
  /**
282
288
  * Reads the `prompt` array, part by part.
283
289
  *
@@ -429,6 +435,7 @@ function parseTasks(report, value) {
429
435
  model: report.string(`${path}.model`, held.model),
430
436
  endpoint: parseEndpoint(report, `${path}.endpoint`, held.endpoint),
431
437
  ...parseSampling(report, path, held),
438
+ extraBody: parseExtraBody(report, `${path}.extraBody`, held.extraBody),
432
439
  });
433
440
  }
434
441
  return tasks;
@@ -767,16 +774,18 @@ function mergeById(items, idOf) {
767
774
  * @param layers - The documents, weakest first. None is written to.
768
775
  * @param endpoint - The agent's merged endpoint. Read, not written to.
769
776
  * @returns The tasks whose merged `model` is not empty, keyed as the documents keyed them — one a
770
- * layer declined with `model: ""` is not among them.
777
+ * layer declined with `model: ""` is not among them. A task's `extraBody` merges by key.
771
778
  */
772
779
  function resolveTasks(layers, endpoint) {
773
780
  const merged = new Map();
774
781
  for (const layer of layers) {
775
782
  for (const [key, task] of Object.entries(layer.tasks ?? {})) {
783
+ const below = merged.get(key);
776
784
  merged.set(key, {
777
- ...(merged.get(key) ?? {}),
785
+ ...below,
778
786
  ...task,
779
- endpoint: mergeLayers([merged.get(key)?.endpoint, task.endpoint]),
787
+ endpoint: mergeLayers([below?.endpoint, task.endpoint]),
788
+ extraBody: mergeLayers([below?.extraBody, task.extraBody]),
780
789
  });
781
790
  }
782
791
  }
@@ -793,6 +802,7 @@ function resolveTasks(layers, endpoint) {
793
802
  maxTokens: task.maxTokens,
794
803
  temperature: task.temperature,
795
804
  reasoningEffort: task.reasoningEffort,
805
+ extraBody: task.extraBody && said(task.extraBody) ? task.extraBody : undefined,
796
806
  });
797
807
  }
798
808
  return tasks;
@@ -896,8 +906,10 @@ export function resolveAgentSpec(layers) {
896
906
  * The property worth keeping is that **a redacted spec is still a valid spec** — it parses, it
897
907
  * resolves, and what comes back is the same agent pointed at the same servers, needing only the
898
908
  * credentials the importing host supplies itself. So a bundled server loses its `env` and its
899
- * `headers` and nothing else, and nothing outside `bundle` is touched: the format has no
900
- * credential field at any depth, which is the reason there is nothing else to strip.
909
+ * `headers` and nothing else, and nothing outside `bundle` is touched: those two are the only
910
+ * fields the format gives a credential. A server can still carry one where the format gives it no
911
+ * field — an `--api-key` among its `args`, a token in its `url` — and that is kept, because
912
+ * cutting it would point the agent at a different server. `findSpecSecrets` says where to look.
901
913
  */
902
914
  export function exportSpec(spec, { secrets = false } = {}) {
903
915
  if (secrets || !spec.bundle?.mcpServers) {
@@ -911,3 +923,160 @@ export function exportSpec(spec, { secrets = false } = {}) {
911
923
  },
912
924
  };
913
925
  }
926
+ /** Why `findSpecSecrets` named a place. */
927
+ export const SecretSign = {
928
+ /** A URL with a user or a password before its host. */
929
+ Userinfo: 'userinfo',
930
+ /** A URL query parameter whose name reads as a credential's. */
931
+ Query: 'query',
932
+ /** The value of a command-line flag, or of a header written as `Name: value`, so named. */
933
+ Flag: 'flag',
934
+ /** A value with the shape of a credential: a known key prefix, or `Bearer` and a token. */
935
+ Value: 'value',
936
+ /** A key of the server itself whose name reads as a credential's. */
937
+ Field: 'field',
938
+ };
939
+ /** A name that reads as a credential's: `api-key`, `TOKEN`, `Authorization`. */
940
+ const SECRET_NAME = /key|token|secret|passw|auth|credential|bearer/i;
941
+ /** A value shaped like a credential, whatever it is called. */
942
+ const SECRET_VALUE = /^(sk-|pk-|rk-|ghp_|gho_|ghs_|github_pat_|glpat-|xox[abprs]-|AKIA)|\b(bearer|basic)\s+\S/i;
943
+ /** What an absolute URL opens with. */
944
+ const URL_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
945
+ /** A header as one string, `Name: value`, as `--header` takes it. */
946
+ const HEADER = /^([\w-]+):\s*(\S.*)$/;
947
+ /** A flag carrying its own value, `--name=value`. */
948
+ const FLAG_WITH_VALUE = /^(-{1,2}[^=\s]+)=(.+)$/;
949
+ /** The keys of a server `findSpecSecrets` reads for itself, or `exportSpec` strips. */
950
+ const SERVER_KEYS_READ = new Set(['id', 'slug', 'url', 'command', 'args', 'env', 'headers']);
951
+ /**
952
+ * Whether a string is a credential written out, as against a reference to one.
953
+ *
954
+ * @param value - What a field, a flag or a parameter holds.
955
+ * @returns `false` for an empty string and for one holding a `${VAR}`, which names where the
956
+ * credential is and is not it.
957
+ */
958
+ const writtenOut = (value) => value !== '' && VARIABLE.test(value) === false;
959
+ /**
960
+ * Why a URL may carry a credential.
961
+ *
962
+ * @param text - A string, which need not be a URL.
963
+ * @returns `Userinfo`, `Query`, or `undefined` for a URL that shows neither and for a string
964
+ * that is not an absolute URL.
965
+ */
966
+ function urlSign(text) {
967
+ if (URL_SCHEME.test(text) === false || URL.canParse(text) === false) {
968
+ return undefined;
969
+ }
970
+ const url = new URL(text);
971
+ if (writtenOut(url.username) || writtenOut(url.password)) {
972
+ return SecretSign.Userinfo;
973
+ }
974
+ for (const [name, value] of url.searchParams) {
975
+ if (SECRET_NAME.test(name) && writtenOut(value)) {
976
+ return SecretSign.Query;
977
+ }
978
+ }
979
+ return undefined;
980
+ }
981
+ /**
982
+ * Why one word of a command line may be a credential, read on its own.
983
+ *
984
+ * @param word - One argument.
985
+ * @returns A sign, or `undefined` for a word that shows none. A flag with no value of its own
986
+ * shows none here: `argumentSigns` reads the word after it.
987
+ */
988
+ function wordSign(word) {
989
+ if (writtenOut(word) === false) {
990
+ return undefined;
991
+ }
992
+ const flag = FLAG_WITH_VALUE.exec(word);
993
+ if (flag?.[1] !== undefined && SECRET_NAME.test(flag[1])) {
994
+ return SecretSign.Flag;
995
+ }
996
+ const header = HEADER.exec(word);
997
+ if (header?.[1] !== undefined && SECRET_NAME.test(header[1])) {
998
+ return SecretSign.Flag;
999
+ }
1000
+ // A flag's own value is read as a word too: `--url=https://user:pass@host`.
1001
+ const value = flag?.[2] ?? word;
1002
+ return SECRET_VALUE.test(value) ? SecretSign.Value : urlSign(value);
1003
+ }
1004
+ /**
1005
+ * The words of a command line that may be credentials.
1006
+ *
1007
+ * @param words - The arguments in order. One that is not a string is passed over.
1008
+ * @returns Each suspect word's index and its sign, in order, at most one sign a word.
1009
+ *
1010
+ * @remarks
1011
+ * A flag named like a credential marks the word after it, unless that word is another flag.
1012
+ */
1013
+ function argumentSigns(words) {
1014
+ const signs = [];
1015
+ let flagged = false;
1016
+ for (const [index, word] of words.entries()) {
1017
+ if (typeof word !== 'string') {
1018
+ flagged = false;
1019
+ continue;
1020
+ }
1021
+ const isFlag = word.startsWith('-');
1022
+ const sign = flagged && isFlag === false && writtenOut(word) ? SecretSign.Flag : wordSign(word);
1023
+ if (sign) {
1024
+ signs.push([index, sign]);
1025
+ }
1026
+ flagged = isFlag && word.includes('=') === false && SECRET_NAME.test(word);
1027
+ }
1028
+ return signs;
1029
+ }
1030
+ /**
1031
+ * The places one bundled server may carry a credential, outside its `env` and `headers`.
1032
+ *
1033
+ * @param server - The server as the document holds it.
1034
+ * @param path - Where the server is, which each hint's path opens with.
1035
+ * @returns The hints in the order `url`, `command`, `args`, then the server's other keys.
1036
+ */
1037
+ function serverSecrets(server, path) {
1038
+ const name = server.slug ?? server.id ?? '';
1039
+ const hints = [];
1040
+ const hint = (at, sign) => {
1041
+ if (sign) {
1042
+ hints.push({ path: `${path}.${at}`, server: name, sign });
1043
+ }
1044
+ };
1045
+ if (typeof server.url === 'string') {
1046
+ hint('url', urlSign(server.url));
1047
+ }
1048
+ if (typeof server.command === 'string') {
1049
+ // A command written as one line is read as its words, and named once.
1050
+ hint('command', argumentSigns(server.command.split(/\s+/))[0]?.[1]);
1051
+ }
1052
+ if (Array.isArray(server.args)) {
1053
+ for (const [index, sign] of argumentSigns(server.args)) {
1054
+ hint(`args[${index}]`, sign);
1055
+ }
1056
+ }
1057
+ for (const [key, value] of Object.entries(server)) {
1058
+ const empty = absent(value) || value === '' || (typeof value === 'string' && writtenOut(value) === false);
1059
+ if (SERVER_KEYS_READ.has(key) === false && SECRET_NAME.test(key) && empty === false) {
1060
+ hint(key, SecretSign.Field);
1061
+ }
1062
+ }
1063
+ return hints;
1064
+ }
1065
+ /**
1066
+ * Where a document's bundled servers may still carry a credential after `exportSpec`.
1067
+ *
1068
+ * @param spec - A document, exported or not: `env` and `headers` are not reported either way,
1069
+ * since `exportSpec` strips them.
1070
+ * @returns One hint for each suspect place, in document order, each naming where and why and
1071
+ * none holding the value. Empty for a document with no bundle.
1072
+ *
1073
+ * @remarks
1074
+ * A host calls this before it offers an export as safe to share, and warns with what comes back.
1075
+ * It reads a server's `url`, its `command` and `args`, and the names of its other keys, and is a
1076
+ * guess both ways: a credential in a positional argument with no telling prefix is missed, and a
1077
+ * flag called `--keyboard` is named. A `${VAR}` is a reference and is never named.
1078
+ */
1079
+ export function findSpecSecrets(spec) {
1080
+ const servers = spec.bundle?.mcpServers ?? [];
1081
+ return servers.flatMap((server, index) => serverSecrets(server, `bundle.mcpServers[${index}]`));
1082
+ }
@@ -158,6 +158,8 @@ export interface PreselectOptions {
158
158
  temperature?: number;
159
159
  /** As `SideTaskOptions.reasoningEffort`: absent keeps the no-thinking hints. */
160
160
  reasoningEffort?: string;
161
+ /** As `SideTaskOptions.extraBody`: sampler fields the options cannot spell. */
162
+ extraBody?: Record<string, unknown>;
161
163
  /**
162
164
  * The most tools the choice may name.
163
165
  *
@@ -181,8 +183,8 @@ export interface PreselectOptions {
181
183
  * `toolSelectModel: settings.toolSelectModel || settings.model`.
182
184
  * @param catalog - The servers to choose from.
183
185
  * @param prompt - The request being planned for. Only its head is read; see `preselectInput`.
184
- * @param [options] - Cancellation, notices, the reply ceiling, the temperature and reasoning effort
185
- * as `ask` reads them, the cap the choice is held to, and whether to try the words first.
186
+ * @param [options] - Cancellation, notices, the reply ceiling, the temperature, reasoning effort and
187
+ * extra fields as `ask` reads them, the cap the choice is held to, and whether to try the words first.
186
188
  * @returns Names as the catalogue spells them, never more than `maxPerLoad`. Empty where `model` is
187
189
  * empty, the catalogue has no tools, the call failed, or its reply named nothing in the catalogue.
188
190
  *
@@ -198,4 +200,4 @@ export interface PreselectOptions {
198
200
  * starting now and starting in a few seconds. The words have to be clear about it; see
199
201
  * `preselectByKeywords` for what that means.
200
202
  */
201
- export declare function preselect(config: Endpoint, model: string, catalog: CatalogServer[], prompt: string, { signal, onNotice, maxTokens, temperature, reasoningEffort, maxPerLoad, keywords, }?: PreselectOptions): Promise<string[]>;
203
+ export declare function preselect(config: Endpoint, model: string, catalog: CatalogServer[], prompt: string, { signal, onNotice, maxTokens, temperature, reasoningEffort, extraBody, maxPerLoad, keywords, }?: PreselectOptions): Promise<string[]>;
@@ -248,8 +248,8 @@ export function preselectByKeywords(catalog, prompt, { maxPerLoad = MAX_PER_LOAD
248
248
  * `toolSelectModel: settings.toolSelectModel || settings.model`.
249
249
  * @param catalog - The servers to choose from.
250
250
  * @param prompt - The request being planned for. Only its head is read; see `preselectInput`.
251
- * @param [options] - Cancellation, notices, the reply ceiling, the temperature and reasoning effort
252
- * as `ask` reads them, the cap the choice is held to, and whether to try the words first.
251
+ * @param [options] - Cancellation, notices, the reply ceiling, the temperature, reasoning effort and
252
+ * extra fields as `ask` reads them, the cap the choice is held to, and whether to try the words first.
253
253
  * @returns Names as the catalogue spells them, never more than `maxPerLoad`. Empty where `model` is
254
254
  * empty, the catalogue has no tools, the call failed, or its reply named nothing in the catalogue.
255
255
  *
@@ -265,7 +265,7 @@ export function preselectByKeywords(catalog, prompt, { maxPerLoad = MAX_PER_LOAD
265
265
  * starting now and starting in a few seconds. The words have to be clear about it; see
266
266
  * `preselectByKeywords` for what that means.
267
267
  */
268
- export async function preselect(config, model, catalog, prompt, { signal, onNotice, maxTokens = 256, temperature, reasoningEffort, maxPerLoad = MAX_PER_LOAD, keywords, } = {}) {
268
+ export async function preselect(config, model, catalog, prompt, { signal, onNotice, maxTokens = 256, temperature, reasoningEffort, extraBody, maxPerLoad = MAX_PER_LOAD, keywords, } = {}) {
269
269
  if (!model || !catalog.some((server) => server.tools.length > 0)) {
270
270
  return [];
271
271
  }
@@ -285,6 +285,7 @@ export async function preselect(config, model, catalog, prompt, { signal, onNoti
285
285
  maxTokens,
286
286
  temperature,
287
287
  reasoningEffort,
288
+ extraBody,
288
289
  signal,
289
290
  onNotice,
290
291
  }), { onNotice, signal });
@@ -55,6 +55,16 @@ export interface SideTaskOptions {
55
55
  * refused it by value, and `chat_template_kwargs` is left out of the request altogether.
56
56
  */
57
57
  reasoningEffort?: string;
58
+ /**
59
+ * Request fields these options cannot spell, as `ModelParams.extraBody` is for the main model:
60
+ * `top_p`, `top_k`, `min_p` and the penalties a model card asks for.
61
+ *
62
+ * @remarks
63
+ * Merged in after the ceiling, temperature and thinking fields, so it may override them, and
64
+ * before `askJson`'s `response_format`. `model`, `messages`, `stream` and `tools` are dropped. A
65
+ * field the endpoint refuses by name is latched for the model and the request sent again without it.
66
+ */
67
+ extraBody?: Record<string, unknown>;
58
68
  /** Abandons the call, usually because the run it supports has gone away. */
59
69
  signal?: AbortSignal;
60
70
  /**
@@ -97,6 +107,8 @@ export interface SideTask {
97
107
  temperature?: number;
98
108
  /** As `SideTaskOptions.reasoningEffort`: a level replaces the no-thinking hints. */
99
109
  reasoningEffort?: string;
110
+ /** As `SideTaskOptions.extraBody`: the sampler fields a model card asks for, sent as given. */
111
+ extraBody?: Record<string, unknown>;
100
112
  }
101
113
  /**
102
114
  * Runs a side task and returns the reply text, thinking stripped. Throws like any request.
@@ -105,7 +117,7 @@ export interface SideTask {
105
117
  * @param model - The model to ask, usually smaller than the one running the work.
106
118
  * @param system - The instruction.
107
119
  * @param user - The input it applies to. Content parts where the model is being shown an image.
108
- * @param [options] - Reply ceiling, temperature, a reasoning effort, cancellation, notices.
120
+ * @param [options] - Reply ceiling, temperature, a reasoning effort, extra fields, cancellation, notices.
109
121
  * @returns The reply's content, trimmed. Where that is empty once the thinking is stripped, a
110
122
  * `reasoning_content` the server sent beside it, stripped the same way, or an empty string.
111
123
  */
@@ -219,6 +231,7 @@ export declare const listLines: (text: string, max: number, maxChars: number) =>
219
231
  * arrives through `options` without a host's call changing. What the task states wins over the
220
232
  * same field in `options`: the host's code is written once and the task is what an operator
221
233
  * configured over it, so a host passes its own ceiling as the value for a task that names none.
234
+ * `extraBody` is merged key by key, the task's keys winning, as layers merge it.
222
235
  * A setting the task leaves out and `options` does too is absent from the result, which is what
223
236
  * leaves it to the entry point's own default — 0.3, not the agent's temperature.
224
237
  *
@@ -1,5 +1,5 @@
1
1
  import { scoped } from '../core/scope.js';
2
- import { capabilitiesFor, ceilingAndTemperature, EFFORT_NONE, effortFor, modelCapabilitiesFor, negotiate, } from '../endpoint/capabilities.js';
2
+ import { capabilitiesFor, ceilingAndTemperature, EFFORT_NONE, effortFor, extraFields, modelCapabilitiesFor, negotiate, } from '../endpoint/capabilities.js';
3
3
  import { endpointId, getClient, modelKey, resolveApiKey, sameUrl } from '../endpoint/client.js';
4
4
  import { relaxSchema, sanitizeSchema } from '../endpoint/schema-compat.js';
5
5
  import { errorMessage } from '../wire/errors.js';
@@ -68,7 +68,7 @@ export const resetHints = () => noHints().clear();
68
68
  * @param model - The model to ask, usually smaller than the one running the work.
69
69
  * @param system - The instruction.
70
70
  * @param user - The input it applies to. Content parts where the model is being shown an image.
71
- * @param [options] - Reply ceiling, temperature, a reasoning effort, cancellation, notices.
71
+ * @param [options] - Reply ceiling, temperature, a reasoning effort, extra fields, cancellation, notices.
72
72
  * @returns The reply's content, trimmed. Where that is empty once the thinking is stripped, a
73
73
  * `reasoning_content` the server sent beside it, stripped the same way, or an empty string.
74
74
  */
@@ -86,13 +86,14 @@ export function ask(config, model, system, user, options = {}) {
86
86
  * @param user - The input, sent as the user message as it was given.
87
87
  * @param options - A side task's options. A `reasoningEffort` that is a level leaves the
88
88
  * no-thinking hints out; absent, `""` or `"off"` sends them unless this pair has refused them.
89
+ * An `extraBody` goes out less the fields this model has refused by name.
89
90
  * @param [format] - Builds the extra body fields from the endpoint's capabilities and the model's.
90
91
  * Called on every send, so a re-send is built from what has been latched since. Absent adds none.
91
92
  * @returns The first choice's message as `answerOf` reads it, empty when there was no answer.
92
93
  * Throws what the request throws, except that a 400 or 422 `negotiate` did not answer, on a
93
94
  * request that carried the hints or an effort, is retried once without either.
94
95
  */
95
- async function complete(config, model, system, user, { maxTokens = 512, temperature = 0.3, reasoningEffort, signal, onNotice }, format) {
96
+ async function complete(config, model, system, user, { maxTokens = 512, temperature = 0.3, reasoningEffort, extraBody, signal, onNotice }, format) {
96
97
  // The effort the caller chose, or empty where it chose none: `""` and `"off"` read as absent,
97
98
  // since off is what the hints below already ask for.
98
99
  const level = effortFor(undefined, reasoningEffort);
@@ -120,6 +121,7 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
120
121
  // Not gated on `hints`: a model that refuses `chat_template_kwargs` may still read the
121
122
  // effort, and the two latches would otherwise contradict each other.
122
123
  ...(sentEffort ? { reasoning_effort: asked } : {}),
124
+ ...extraFields(refused, extraBody),
123
125
  ...(format && refused ? format(supports, refused) : {}),
124
126
  }, { signal });
125
127
  };
@@ -131,6 +133,7 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
131
133
  const attempt = (hints, effort) => negotiate(supports, (latched, _produced, refused) => send(hints, effort, latched, refused), {
132
134
  model,
133
135
  onNotice,
136
+ droppable: Object.keys(extraBody ?? {}),
134
137
  });
135
138
  const key = modelKey(endpointId(config), model);
136
139
  // A chosen effort replaces the template hint rather than riding beside it: `enable_thinking:
@@ -333,6 +336,7 @@ export const listLines = (text, max, maxChars) => text
333
336
  * arrives through `options` without a host's call changing. What the task states wins over the
334
337
  * same field in `options`: the host's code is written once and the task is what an operator
335
338
  * configured over it, so a host passes its own ceiling as the value for a task that names none.
339
+ * `extraBody` is merged key by key, the task's keys winning, as layers merge it.
336
340
  * A setting the task leaves out and `options` does too is absent from the result, which is what
337
341
  * leaves it to the entry point's own default — 0.3, not the agent's temperature.
338
342
  *
@@ -356,6 +360,9 @@ export function taskCall(task, options, agent) {
356
360
  if (task.reasoningEffort !== undefined) {
357
361
  stated.reasoningEffort = task.reasoningEffort;
358
362
  }
363
+ if (task.extraBody !== undefined) {
364
+ stated.extraBody = { ...options?.extraBody, ...task.extraBody };
365
+ }
359
366
  return {
360
367
  endpoint: agent
361
368
  ? {
package/llms.txt CHANGED
@@ -220,6 +220,7 @@ A JSON document that defines an agent, and the rules for reading one.
220
220
  - `EndpointSpec` (type) — Where a model is reached, and how long to wait for it.
221
221
  - `ExportSpecOptions` (type) — What `exportSpec` takes besides the document.
222
222
  - `exportSpec` — A document fit to leave the host: the same agent, with the secrets of its bundled servers gone.
223
+ - `findSpecSecrets` — Where a document's bundled servers may still carry a credential after `exportSpec`.
223
224
  - `ModelSpec` (type) — Which model answers and how, including the request fields this interface cannot spell.
224
225
  - `ParsedSpec` (type) — What `parseSpec` hands back.
225
226
  - `ParseSpecOptions` (type) — What `parseSpec` takes besides the document.
@@ -232,6 +233,8 @@ A JSON document that defines an agent, and the rules for reading one.
232
233
  - `ResolvedTask` (type) — One side task, resolved: a model, and the endpoint it is reached through.
233
234
  - `RetrySpec` (type) — What to do about a request lost before the model produced anything.
234
235
  - `resolveAgentSpec` — Layers documents into the flat object the loop takes, weakest first.
236
+ - `SecretHint` (type) — One place `findSpecSecrets` thinks a credential may be, and never the credential.
237
+ - `SecretSign` — Any one of the reasons in `SecretSign`.
235
238
  - `SPEC_EVENTS` — Every event a hook may be bound to: `HOOK_EVENTS`, under the name a document's reader knows.
236
239
  - `SpecBundle` (type) — What an agent carries so it can travel whole: the definitions of the servers it names.
237
240
  - `SpecServer` (type) — A bundled MCP server, validated only as far as a round trip needs and otherwise passed through.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "4.1.0",
3
+ "version": "4.3.0",
4
4
  "description": "The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.",
5
5
  "keywords": [
6
6
  "openai",