@retinue/agentkit 0.1.0 → 0.2.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 (77) hide show
  1. package/README.md +59 -277
  2. package/dist/adapters/embeddings/openai.d.ts +45 -0
  3. package/dist/adapters/embeddings/openai.js +109 -0
  4. package/dist/agents/agent.d.ts +22 -1
  5. package/dist/agents/agent.js +97 -11
  6. package/dist/agents/engine.d.ts +28 -0
  7. package/dist/agents/engine.js +194 -8
  8. package/dist/capabilities/index.d.ts +5 -1
  9. package/dist/capabilities/index.js +23 -0
  10. package/dist/capabilities/runtime.d.ts +8 -0
  11. package/dist/core/budget.d.ts +55 -0
  12. package/dist/core/budget.js +56 -0
  13. package/dist/core/content-parts.d.ts +8 -0
  14. package/dist/core/events.d.ts +68 -2
  15. package/dist/core/events.js +2 -0
  16. package/dist/core/index.d.ts +1 -0
  17. package/dist/core/index.js +1 -0
  18. package/dist/documents/index.d.ts +14 -0
  19. package/dist/documents/parsers/text.d.ts +16 -0
  20. package/dist/documents/parsers/text.js +54 -2
  21. package/dist/entries/guardrails.d.ts +14 -0
  22. package/dist/entries/guardrails.js +14 -0
  23. package/dist/entries/knowledge.d.ts +9 -0
  24. package/dist/entries/knowledge.js +8 -0
  25. package/dist/graphql/resolvers.d.ts +4 -0
  26. package/dist/graphql/resolvers.js +6 -0
  27. package/dist/graphql/schema.d.ts +1 -1
  28. package/dist/graphql/schema.js +44 -0
  29. package/dist/guardrails/index.d.ts +115 -0
  30. package/dist/guardrails/index.js +108 -0
  31. package/dist/guardrails/moderation.d.ts +53 -0
  32. package/dist/guardrails/moderation.js +75 -0
  33. package/dist/guardrails/pii.d.ts +75 -0
  34. package/dist/guardrails/pii.js +193 -0
  35. package/dist/knowledge/index.d.ts +1 -0
  36. package/dist/knowledge/index.js +1 -0
  37. package/dist/knowledge/navigate.d.ts +89 -0
  38. package/dist/knowledge/navigate.js +107 -0
  39. package/dist/knowledge/retrieval.d.ts +73 -5
  40. package/dist/knowledge/retrieval.js +82 -28
  41. package/dist/models/streaming.d.ts +22 -1
  42. package/dist/models/streaming.js +5 -1
  43. package/dist/security/checklist.js +9 -0
  44. package/dist/security/findings.js +18 -9
  45. package/dist/skills/catalogue.d.ts +49 -0
  46. package/dist/skills/catalogue.js +61 -0
  47. package/dist/skills/index.d.ts +1 -0
  48. package/dist/skills/index.js +1 -0
  49. package/dist/telemetry/spans.js +12 -0
  50. package/dist/toolkit/files.d.ts +125 -0
  51. package/dist/toolkit/files.js +320 -0
  52. package/dist/toolkit/index.d.ts +4 -0
  53. package/dist/toolkit/index.js +2 -0
  54. package/dist/toolkit/sandbox.d.ts +119 -0
  55. package/dist/toolkit/sandbox.js +239 -0
  56. package/dist/toolkit/web.d.ts +13 -0
  57. package/dist/toolkit/web.js +7 -1
  58. package/dist/tools/budget.d.ts +28 -0
  59. package/dist/tools/budget.js +35 -0
  60. package/dist/tools/credentials.d.ts +57 -0
  61. package/dist/tools/credentials.js +54 -0
  62. package/dist/tools/define.d.ts +31 -0
  63. package/dist/tools/define.js +23 -0
  64. package/dist/tools/find.d.ts +109 -0
  65. package/dist/tools/find.js +210 -0
  66. package/dist/tools/index.d.ts +14 -2
  67. package/dist/tools/index.js +4 -0
  68. package/dist/tools/library/fs.d.ts +24 -0
  69. package/dist/tools/library/fs.js +102 -0
  70. package/dist/tools/library/index.d.ts +29 -2
  71. package/dist/tools/library/index.js +40 -0
  72. package/dist/tools/library/shell.d.ts +45 -0
  73. package/dist/tools/library/shell.js +70 -0
  74. package/dist/tools/meta-tools.js +8 -0
  75. package/dist/tools/registry.d.ts +113 -0
  76. package/dist/tools/registry.js +180 -4
  77. package/package.json +5 -1
@@ -56,7 +56,13 @@ export const createAgent = (config) => {
56
56
  });
57
57
  const providerFactory = createProviderFactory({ credentials: config.providerCredentials ?? {} });
58
58
  const authorization = config.authorization ?? allowAllAuthorization();
59
- const toolRegistry = createToolRegistry({ providers: config.tools ?? [], authorization });
59
+ const toolRegistry = createToolRegistry({
60
+ providers: config.tools ?? [],
61
+ authorization,
62
+ ...(config.toolSearch === undefined ? {} : { search: config.toolSearch }),
63
+ ...(config.toolsets === undefined ? {} : { toolsets: config.toolsets }),
64
+ ...(config.catalogBudget === undefined ? {} : { catalogBudget: config.catalogBudget }),
65
+ });
60
66
  const resolveModel = config.resolveModel ??
61
67
  ((m) => {
62
68
  const def = registry.resolve(m.modelPolicy);
@@ -69,6 +75,8 @@ export const createAgent = (config) => {
69
75
  });
70
76
  const contextProviders = config.contextProviders ?? [];
71
77
  const engine = config.engine ?? createDefaultEngine({
78
+ ...(config.guardrails === undefined ? {} : { guardrails: config.guardrails }),
79
+ ...(config.catalogBudget === undefined ? {} : { catalogBudget: config.catalogBudget }),
72
80
  async loadManifest() {
73
81
  return manifest; // single-manifest embedded agent
74
82
  },
@@ -120,19 +128,97 @@ export const createAgent = (config) => {
120
128
  },
121
129
  ...(config.tools && config.tools.length > 0
122
130
  ? {
131
+ /**
132
+ * The tools this turn, through the registry rather than around it — task #210.
133
+ *
134
+ * This used to gather the providers itself and filter them with `authorization.filterTools`, which was
135
+ * a second copy of what the registry does: no duplicate-name check, and — once tenant toolsets existed
136
+ * — no toolset either, so a switched-off category was hidden everywhere except in the list actually
137
+ * handed to the model.
138
+ */
123
139
  buildTools: async (context) => {
124
- const resolvedTools = [];
125
- const gathered = (await Promise.all((config.tools ?? []).map((p) => p.listTools(context)))).flat();
126
- const permitted = new Set((await authorization.filterTools(context, gathered.map((t) => t.descriptor))).map((d) => d.name));
127
- for (const t of gathered) {
128
- if (!permitted.has(t.descriptor.name))
129
- continue;
140
+ const descriptors = await toolRegistry.listAuthorized(context);
141
+ const resolvedTools = descriptors.map((descriptor) => ({
142
+ name: descriptor.name,
143
+ description: descriptor.description,
144
+ inputSchema: descriptor.inputSchema,
145
+ execute: async (input) => {
146
+ const result = await toolRegistry.execute(context, { name: descriptor.name, input });
147
+ if (!result.ok)
148
+ throw new AgentPlatformError(result.error);
149
+ return result.data;
150
+ },
151
+ }));
152
+ /**
153
+ * `find_tools`, when a search is wired — task #210, AC-1.
154
+ *
155
+ * Its schema is written here because a meta-tool descriptor carries none, and a model handed a
156
+ * permissive schema streams `{}` for every call. The registry validates the arguments itself.
157
+ */
158
+ /**
159
+ * `execute_tool`, alongside search — task #210.
160
+ *
161
+ * Without it `find_tools` is a dead end: the tool it names is not in this turn's list (that is why
162
+ * it had to be searched for), so the model learns a name it cannot call. Added whenever search or a
163
+ * budget is configured, which are exactly the cases where the list is partial.
164
+ */
165
+ if (config.toolSearch !== undefined || config.catalogBudget !== undefined) {
166
+ resolvedTools.push({
167
+ name: "execute_tool",
168
+ description: "Run a tool by name — including one that is not listed here, for example one you found with " +
169
+ "find_tools. Authorization and approval apply exactly as they would to a direct call.",
170
+ inputSchema: {
171
+ type: "object",
172
+ properties: {
173
+ name: { type: "string", description: "The tool's name." },
174
+ input: { type: "object", description: "The tool's arguments, matching its schema." },
175
+ },
176
+ required: ["name", "input"],
177
+ },
178
+ execute: async (input, options) => {
179
+ const result = await toolRegistry.execute(context, { name: "execute_tool", input });
180
+ // What actually ran, so the run event log names the action rather than the mechanism.
181
+ if (result.ranToolName !== undefined)
182
+ options?.report?.({ ranToolName: result.ranToolName });
183
+ if (!result.ok)
184
+ throw new AgentPlatformError(result.error);
185
+ return result.data;
186
+ },
187
+ });
188
+ }
189
+ if (config.toolSearch !== undefined || config.catalogBudget !== undefined) {
130
190
  resolvedTools.push({
131
- name: t.descriptor.name,
132
- description: t.descriptor.description,
133
- inputSchema: t.descriptor.inputSchema,
191
+ name: "learn_tools",
192
+ description: "Fetch the full input schemas for tools by name — including ones not listed here. Call this " +
193
+ "before running a tool you found with find_tools, so you know what arguments it takes.",
194
+ inputSchema: {
195
+ type: "object",
196
+ properties: { names: { type: "array", items: { type: "string" } } },
197
+ required: ["names"],
198
+ },
199
+ execute: async (input) => {
200
+ const result = await toolRegistry.execute(context, { name: "learn_tools", input });
201
+ if (!result.ok)
202
+ throw new AgentPlatformError(result.error);
203
+ return result.data;
204
+ },
205
+ });
206
+ }
207
+ if (config.toolSearch !== undefined) {
208
+ resolvedTools.push({
209
+ name: "find_tools",
210
+ description: "Search for a tool by describing what you need to do. Not all available tools are listed, so " +
211
+ "search before concluding that something cannot be done. Run what you find with execute_tool.",
212
+ inputSchema: {
213
+ type: "object",
214
+ properties: {
215
+ query: { type: "string", description: "What you are trying to do, in your own words." },
216
+ limit: { type: "number", description: "How many tools to return. Default 10." },
217
+ },
218
+ required: ["query"],
219
+ },
134
220
  execute: async (input) => {
135
- const result = await toolRegistry.execute(context, { name: t.descriptor.name, input });
221
+ const result = await toolRegistry.execute(context, { name: "find_tools", input });
136
222
  if (!result.ok)
137
223
  throw new AgentPlatformError(result.error);
138
224
  return result.data;
@@ -20,10 +20,20 @@
20
20
  import type { ExecutionContext } from "../core/context.js";
21
21
  import type { RunId, TenantId } from "../core/ids.js";
22
22
  import type { ModelDefinition, ModelTurnRequest, ModelTurnTool, NeutralStreamChunk, NeutralUsage, ResolvedModel, TurnMessage } from "../models/index.js";
23
+ import { type Guardrail } from "../guardrails/index.js";
23
24
  import { type AgentEngine, type RetryPolicy, type Run } from "../runtime/index.js";
25
+ import { type TokenBudget } from "../core/budget.js";
24
26
  import type { PendingQuestion, RunApprovals } from "../hitl/index.js";
25
27
  import type { CitationEmitter } from "../citations/index.js";
26
28
  import type { AgentManifest } from "./index.js";
29
+ /**
30
+ * What one tool costs the model's context.
31
+ *
32
+ * A `ModelTurnTool` is not a catalogue entry — it carries the full input schema, because that is what a provider
33
+ * puts in the request — so this deliberately does *not* reuse `entryTokens`. Using the compact estimate here
34
+ * would understate a schema-heavy tool by an order of magnitude and produce a budget that never binds.
35
+ */
36
+ export declare const turnToolTokens: (tool: ModelTurnTool) => number;
27
37
  /** A model resolved for a turn: the opaque handle plus what the engine needs to attribute usage. */
28
38
  export type ResolvedModelInfo = {
29
39
  readonly model: ResolvedModel;
@@ -63,6 +73,24 @@ export type DefaultEngineDeps = {
63
73
  * pause on.
64
74
  */
65
75
  readonly approvals?: RunApprovals;
76
+ /**
77
+ * Checks a deployment adds — REQ-046 (#205).
78
+ *
79
+ * Absent means no inspection, which is the current behaviour and stays the default: a runtime that imposed a
80
+ * model call on every turn to moderate it would be making a cost decision that belongs to the host.
81
+ */
82
+ readonly guardrails?: readonly Guardrail[];
83
+ /**
84
+ * A ceiling in tokens on the tool list handed to the model — REQ-045 (#204), task #210, AC-3.
85
+ *
86
+ * Here rather than only in the registry because *this* is where the tokens are actually spent: the registry's
87
+ * catalogue is what a client renders, and `buildTools` is what reaches the provider. A budget enforced in one
88
+ * and not the other would be a budget a deployment believes it has.
89
+ *
90
+ * Absent means no ceiling, which stays the default — a runtime that silently withheld tools from a model
91
+ * nobody had asked it to withhold would be making a correctness decision on the host's behalf.
92
+ */
93
+ readonly catalogBudget?: TokenBudget;
66
94
  /**
67
95
  * The question side of resumption — #163.
68
96
  *
@@ -19,9 +19,23 @@
19
19
  */
20
20
  import { AgentPlatformError, isAgentPlatformError } from "../core/errors.js";
21
21
  import { asId } from "../core/ids.js";
22
- import { streamModelTurn } from "../models/index.js";
22
+ import { applyInputGuardrails, applyOutputGuardrails } from "../guardrails/index.js";
23
+ import { streamModelTurn, turnText } from "../models/index.js";
23
24
  import { decideRetry, deriveRunMessageId, toPlatformError, DEFAULT_RETRY_POLICY, } from "../runtime/index.js";
25
+ import { META_TOOLS } from "../tools/index.js";
26
+ // The specific modules rather than `core/index.js`: the barrel pulls in `zod` through `core/validation.ts`,
27
+ // and a subpath's dependency graph is a guarantee this package tests for.
28
+ import { applyTokenBudget } from "../core/budget.js";
29
+ import { estimateTokens } from "../core/tokens.js";
24
30
  import { isQuestionPending } from "../hitl/service.js";
31
+ /**
32
+ * What one tool costs the model's context.
33
+ *
34
+ * A `ModelTurnTool` is not a catalogue entry — it carries the full input schema, because that is what a provider
35
+ * puts in the request — so this deliberately does *not* reuse `entryTokens`. Using the compact estimate here
36
+ * would understate a schema-heavy tool by an order of magnitude and produce a budget that never binds.
37
+ */
38
+ export const turnToolTokens = (tool) => estimateTokens(`${tool.name} ${tool.description ?? ""}`) + estimateTokens(JSON.stringify(tool.inputSchema ?? {}));
25
39
  const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
26
40
  /**
27
41
  * Take citation candidates off a tool's result — #165.
@@ -83,6 +97,21 @@ const questionMarker = (thrown, toolName) => {
83
97
  },
84
98
  };
85
99
  };
100
+ /**
101
+ * A record becomes an event, with no value ever attached.
102
+ *
103
+ * One place, so a future field on `GuardrailRecord` cannot reach the event log by being spread in somewhere: the
104
+ * mapping is explicit, field by field, and adding one here is a decision rather than a consequence.
105
+ */
106
+ const verdictEvent = (record) => ({
107
+ type: "guardrail.verdict",
108
+ guardrail: record.guardrail,
109
+ subject: record.subject,
110
+ outcome: record.outcome,
111
+ ...(record.what === undefined ? {} : { what: record.what }),
112
+ ...(record.code === undefined ? {} : { code: record.code }),
113
+ ...(record.threw === undefined ? {} : { threw: record.threw }),
114
+ });
86
115
  export const createDefaultEngine = (deps) => {
87
116
  const policy = deps.retry ?? DEFAULT_RETRY_POLICY;
88
117
  const sleep = deps.sleep ?? defaultSleep;
@@ -94,7 +123,39 @@ export const createDefaultEngine = (deps) => {
94
123
  const resolved = deps.resolveModel(manifest, context);
95
124
  const system = (await (deps.systemPrompt?.(manifest, context) ?? manifest.instructions)) || undefined;
96
125
  const history = await deps.loadHistory(context, run);
97
- const declared = deps.buildTools ? await deps.buildTools(context, manifest) : [];
126
+ const built = deps.buildTools ? await deps.buildTools(context, manifest) : [];
127
+ /**
128
+ * The budget, applied to the list the model will actually see — AC-3.
129
+ *
130
+ * Meta-tools are protected: dropping `find_tools` to save its own ~35 tokens would leave the model with a
131
+ * shortened list and no way to discover that it was shortened, which is the failure this whole mechanism
132
+ * exists to prevent.
133
+ *
134
+ * `findable` is *derived* rather than configured. Whether truncation is a deferral or an amputation
135
+ * depends on one fact — is `find_tools` in the model's hands this turn — and asking the host to declare
136
+ * that separately would let the declaration be wrong.
137
+ */
138
+ const budgetOutcome = deps.catalogBudget === undefined
139
+ ? undefined
140
+ : applyTokenBudget({
141
+ items: built,
142
+ budget: deps.catalogBudget,
143
+ tokensOf: turnToolTokens,
144
+ nameOf: (tool) => tool.name,
145
+ protect: (tool) => META_TOOLS.includes(tool.name),
146
+ });
147
+ const declared = budgetOutcome?.resident ?? built;
148
+ if (budgetOutcome !== undefined && (budgetOutcome.dropped.length > 0 || budgetOutcome.overBudget)) {
149
+ yield {
150
+ type: "catalog.truncated",
151
+ catalog: "tools",
152
+ budgetTokens: budgetOutcome.budgetTokens,
153
+ residentTokens: budgetOutcome.residentTokens,
154
+ dropped: budgetOutcome.dropped,
155
+ findable: declared.some((tool) => tool.name === "find_tools"),
156
+ ...(budgetOutcome.overBudget ? { overBudget: true } : {}),
157
+ };
158
+ }
98
159
  const maxSteps = manifest.limits?.maxSteps ?? 8;
99
160
  const messageId = deriveRunMessageId(run.id);
100
161
  // A decision taken while the run was parked. Executed before the model gets another turn, so the
@@ -113,6 +174,46 @@ export const createDefaultEngine = (deps) => {
113
174
  for (const event of questionEvents(answered, messages))
114
175
  yield event;
115
176
  }
177
+ /**
178
+ * Inspection, before the model sees anything — REQ-046 (#205), AC-1.
179
+ *
180
+ * Placed after resumption so an approved side effect that already happened is not re-inspected, and
181
+ * *before* the tools are built so a refusal costs nothing: no provider call, no tool discovery, no spend.
182
+ *
183
+ * The subject is the newest user turn rather than the whole history. Re-inspecting history every turn
184
+ * would re-refuse a conversation over something already allowed, and a guardrail that changes its mind
185
+ * about the past makes a conversation impossible to continue.
186
+ */
187
+ const guardrails = deps.guardrails ?? [];
188
+ if (guardrails.length > 0) {
189
+ const latest = [...messages].reverse().find((m) => m.role === "user");
190
+ const decision = await applyInputGuardrails(guardrails, { text: latest ? turnText(latest) : "" }, context);
191
+ for (const record of decision.records)
192
+ yield verdictEvent(record);
193
+ if (decision.outcome === "refused") {
194
+ /**
195
+ * The turn ends here, and it ends *visibly*.
196
+ *
197
+ * A text part rather than a thrown error: a refusal is a policy outcome, not a crash, and a run that
198
+ * failed with a stack trace tells the person nothing and the operator the wrong thing. The model is
199
+ * never called, which is what AC-3 asks for — "the turn does not proceed".
200
+ */
201
+ const partId = `${messageId}:guardrail:refused`;
202
+ yield {
203
+ type: "part.added",
204
+ messageId,
205
+ part: {
206
+ id: partId,
207
+ type: "text",
208
+ schemaVersion: 1,
209
+ createdAt: new Date(0).toISOString(),
210
+ text: decision.message,
211
+ },
212
+ };
213
+ yield { type: "run.completed" };
214
+ return;
215
+ }
216
+ }
116
217
  /**
117
218
  * The tools the model may call, with execution routed through the approval loop.
118
219
  *
@@ -139,6 +240,15 @@ export const createDefaultEngine = (deps) => {
139
240
  * the passage supports. Emitting at the tool call would produce citations supporting nothing.
140
241
  */
141
242
  const pendingCitations = [];
243
+ /**
244
+ * Guardrail verdicts on tool arguments, buffered — REQ-046 (#205), AC-2 and AC-4.
245
+ *
246
+ * Buffered for the same reason as the citations above: the inspection happens inside a tool's `execute`,
247
+ * which is a callback the model's stream invokes and not a generator, so it cannot yield. Dropping the
248
+ * records instead would satisfy the enforcement half of AC-2 and quietly fail AC-4 — the check would work
249
+ * and leave no trace, which is the combination that makes an incident unreconstructable.
250
+ */
251
+ const pendingVerdicts = [];
142
252
  const approvals = deps.approvals;
143
253
  /**
144
254
  * Every tool is wrapped, whether or not an approval gate is configured.
@@ -147,12 +257,74 @@ export const createDefaultEngine = (deps) => {
147
257
  * interception point at all — and a question raised by one of its tools could not be noticed. The gate
148
258
  * decides *approvals*; parking a run on a question is not its business.
149
259
  */
260
+ /**
261
+ * What each call actually ran, keyed by the provider's call id — task #210.
262
+ *
263
+ * `execute_tool` names its target, so the tool the model called and the action performed are two different
264
+ * things. The wrapper is the only place that knows both, and the events are emitted somewhere else, so the
265
+ * fact has to be carried across. Without it a `destructive` tool invoked through `execute_tool` appears in
266
+ * the audit trail as "execute_tool", which is not an answer to the question the trail exists to answer.
267
+ */
268
+ const ranByCall = new Map();
150
269
  const tools = declared.map((t) => ({
151
270
  ...t,
152
- execute: async (input) => {
271
+ execute: async (input, options) => {
272
+ /**
273
+ * What this call is allowed to tell us — task #210.
274
+ *
275
+ * The host's closure is what reaches the registry, so it is the only thing that can know a call
276
+ * resolved to a different tool. `report` is how it says so, and the map is read where the events are
277
+ * emitted.
278
+ */
279
+ const report = (fact) => {
280
+ if (options?.toolCallId !== undefined)
281
+ ranByCall.set(options.toolCallId, fact.ranToolName);
282
+ };
283
+ /**
284
+ * A tool call is an output — AC-2.
285
+ *
286
+ * Before either branch below, so it applies whether or not an approval gate is configured, and
287
+ * *before* the gate so a refused call never becomes an approval request: asking a person to approve
288
+ * something that will not happen is how approving comes to feel meaningless.
289
+ *
290
+ * The refusal is returned as a tool *result* rather than thrown. The model then sees why its call did
291
+ * not happen and can say so, which is the difference between a run that explains itself and one that
292
+ * dies with a stack trace the person cannot act on.
293
+ */
294
+ if (guardrails.length > 0) {
295
+ const decision = await applyOutputGuardrails(guardrails, { kind: "tool-call", toolName: t.name, input }, context);
296
+ pendingVerdicts.push(...decision.records);
297
+ if (decision.outcome === "refused") {
298
+ return { refused: true, guardrail: decision.by, code: decision.code, message: decision.message };
299
+ }
300
+ // A redacted call runs with the redaction, not with what the model typed.
301
+ if (decision.value.kind === "tool-call")
302
+ input = decision.value.input;
303
+ }
304
+ /**
305
+ * What a tool hands back is inspected too — AC-3.
306
+ *
307
+ * A tool result is content entering the model's context from outside the tenant, and it is the
308
+ * likeliest source of personal data in a run: a document read by a tool contains whatever the document
309
+ * contains. Checking arguments and not results would guard the direction data leaves and ignore the
310
+ * direction it arrives.
311
+ *
312
+ * A refusal replaces the result rather than throwing, for the same reason as above: the model is told
313
+ * why and can say so, instead of the run dying where the person cannot see the cause.
314
+ */
315
+ const inspectResult = async (output) => {
316
+ if (guardrails.length === 0)
317
+ return output;
318
+ const decision = await applyOutputGuardrails(guardrails, { kind: "tool-result", toolName: t.name, output }, context);
319
+ pendingVerdicts.push(...decision.records);
320
+ if (decision.outcome === "refused") {
321
+ return { refused: true, guardrail: decision.by, code: decision.code, message: decision.message };
322
+ }
323
+ return decision.value.kind === "tool-result" ? decision.value.output : output;
324
+ };
153
325
  if (approvals === undefined) {
154
326
  try {
155
- return collectCitations(await t.execute(input), pendingCitations);
327
+ return collectCitations(await inspectResult(await t.execute(input, { ...(options ?? {}), report })), pendingCitations);
156
328
  }
157
329
  catch (thrown) {
158
330
  const parked = questionMarker(thrown, t.name);
@@ -175,6 +347,9 @@ export const createDefaultEngine = (deps) => {
175
347
  message: `${t.name} needs human approval before it can run. The run is paused; do not retry.`,
176
348
  };
177
349
  }
350
+ // The approval path reaches the registry itself, so the fact needs no host cooperation here.
351
+ if (outcome.result.ranToolName !== undefined)
352
+ report({ ranToolName: outcome.result.ranToolName });
178
353
  if (!outcome.result.ok) {
179
354
  // The registry flattens a delegate's throw into a result, so the question arrives here as a code.
180
355
  const parked = questionMarker(outcome.result.error, t.name);
@@ -184,7 +359,7 @@ export const createDefaultEngine = (deps) => {
184
359
  }
185
360
  throw new AgentPlatformError(outcome.result.error);
186
361
  }
187
- return collectCitations(outcome.result.data, pendingCitations);
362
+ return collectCitations(await inspectResult(outcome.result.data), pendingCitations);
188
363
  },
189
364
  }));
190
365
  let attempt = 1;
@@ -238,7 +413,7 @@ export const createDefaultEngine = (deps) => {
238
413
  controller.abort();
239
414
  return;
240
415
  }
241
- for (const event of mapChunk(chunk, messageId, resolved, textParts)) {
416
+ for (const event of mapChunk(chunk, messageId, resolved, textParts, ranByCall)) {
242
417
  emitted += 1;
243
418
  yield event;
244
419
  }
@@ -270,6 +445,8 @@ export const createDefaultEngine = (deps) => {
270
445
  * After the stream, so a citation cannot appear above text the reader is already looking at — the
271
446
  * append-only property `citationViewModel` depends on.
272
447
  */
448
+ for (const record of pendingVerdicts.splice(0))
449
+ yield verdictEvent(record);
273
450
  if (deps.citations !== undefined && pendingCitations.length > 0) {
274
451
  const claims = [...textParts.values()].map((t) => t.partId);
275
452
  if (claims.length > 0) {
@@ -392,7 +569,9 @@ function* approvalEvents(resumed, messageId, messages) {
392
569
  });
393
570
  }
394
571
  /** Map one neutral chunk to zero or more engine events. Mutates `textParts` to accumulate deltas. */
395
- function* mapChunk(chunk, messageId, resolved, textParts) {
572
+ function* mapChunk(chunk, messageId, resolved, textParts,
573
+ /** What each call resolved to, when it was not what the model named — task #210. */
574
+ ranByCall = new Map()) {
396
575
  switch (chunk.type) {
397
576
  case "text-delta": {
398
577
  const existing = textParts.get(chunk.id);
@@ -425,6 +604,7 @@ function* mapChunk(chunk, messageId, resolved, textParts) {
425
604
  return;
426
605
  }
427
606
  case "tool-result": {
607
+ const ran = ranByCall.get(chunk.toolCallId);
428
608
  const part = {
429
609
  id: `${chunk.toolCallId}:result`,
430
610
  type: "tool-result",
@@ -432,10 +612,16 @@ function* mapChunk(chunk, messageId, resolved, textParts) {
432
612
  createdAt: new Date(0).toISOString(),
433
613
  toolCallId: asId(chunk.toolCallId),
434
614
  toolName: chunk.toolName,
615
+ ...(ran === undefined ? {} : { ranToolName: ran }),
435
616
  output: chunk.output,
436
617
  truncated: false,
437
618
  };
438
- yield { type: "tool.completed", toolCallId: asId(chunk.toolCallId), toolName: chunk.toolName };
619
+ yield {
620
+ type: "tool.completed",
621
+ toolCallId: asId(chunk.toolCallId),
622
+ toolName: chunk.toolName,
623
+ ...(ran === undefined ? {} : { ranToolName: ran }),
624
+ };
439
625
  yield { type: "part.added", messageId, part };
440
626
  return;
441
627
  }
@@ -34,7 +34,7 @@
34
34
  * which auto-approves a stated set of effects, and is auditable afterwards where a flag leaves no record. The
35
35
  * difference is between "nobody had to approve this" and "we cannot tell whether anyone should have".
36
36
  */
37
- export declare const CAPABILITIES: readonly ["history", "memory", "compaction", "citations", "questions", "skills", "mcp", "usage"];
37
+ export declare const CAPABILITIES: readonly ["history", "memory", "compaction", "citations", "questions", "skills", "mcp", "usage", "guardrails", "shell"];
38
38
  export type Capability = (typeof CAPABILITIES)[number];
39
39
  export type CapabilityState = "on" | "off";
40
40
  export type CapabilityMap = Readonly<Record<Capability, CapabilityState>>;
@@ -62,6 +62,8 @@ export declare const PROFILES: {
62
62
  readonly skills: "on";
63
63
  readonly mcp: "off";
64
64
  readonly usage: "on";
65
+ readonly guardrails: "off";
66
+ readonly shell: "off";
65
67
  };
66
68
  /**
67
69
  * A headless automation: no conversation, no person, no recall.
@@ -78,6 +80,8 @@ export declare const PROFILES: {
78
80
  readonly skills: "off";
79
81
  readonly mcp: "off";
80
82
  readonly usage: "on";
83
+ readonly guardrails: "off";
84
+ readonly shell: "off";
81
85
  };
82
86
  };
83
87
  export type ProfileName = keyof typeof PROFILES;
@@ -44,6 +44,8 @@ export const CAPABILITIES = [
44
44
  "skills",
45
45
  "mcp",
46
46
  "usage",
47
+ "guardrails",
48
+ "shell",
47
49
  ];
48
50
  /**
49
51
  * What each capability cannot work without, by the name a host wires it under.
@@ -62,6 +64,18 @@ export const CAPABILITY_REQUIRES = {
62
64
  skills: ["skills"],
63
65
  mcp: ["mcpConnections", "mcpClient"],
64
66
  usage: ["usage"],
67
+ // A guardrail set, supplied by the host. Declaring the capability without wiring one is refused at
68
+ // construction — which is the point: "guardrails: on" must mean a check exists, not that somebody intended one.
69
+ guardrails: ["guardrails"],
70
+ /**
71
+ * A sandbox for `shell_exec` — REQ-047 (#206), task #215.
72
+ *
73
+ * The only capability whose *absence* is a security property rather than a missing feature. `shell_exec` is
74
+ * arbitrary code execution with a natural-language trigger, so it takes two switches: a sandbox wired, and this
75
+ * declared. Declaring it with no sandbox is refused at construction, and wiring a sandbox without declaring it
76
+ * leaves the tool present and refusing — which is the safe direction of the two.
77
+ */
78
+ shell: ["sandbox"],
65
79
  };
66
80
  const OFF = Object.freeze(Object.fromEntries(CAPABILITIES.map((c) => [c, "off"])));
67
81
  /**
@@ -81,6 +95,13 @@ export const PROFILES = {
81
95
  skills: "on",
82
96
  mcp: "off",
83
97
  usage: "on",
98
+ // Off in both profiles, deliberately. A guardrail set is the host's — and a profile that turned this on
99
+ // would be a profile that refuses to construct until somebody supplies one, which is a poor default for a
100
+ // named starting point.
101
+ guardrails: "off",
102
+ // Off in both profiles, and this one should never be otherwise: no named starting point gets to decide that
103
+ // an application can run shell commands.
104
+ shell: "off",
84
105
  },
85
106
  /**
86
107
  * A headless automation: no conversation, no person, no recall.
@@ -97,6 +118,8 @@ export const PROFILES = {
97
118
  skills: "off",
98
119
  mcp: "off",
99
120
  usage: "on",
121
+ guardrails: "off",
122
+ shell: "off",
100
123
  },
101
124
  };
102
125
  /** A profile as a plain map, so a caller can read a default before adopting it. */
@@ -18,6 +18,7 @@
18
18
  * capability. Not a function a caller must remember to call first: the ones who forget are exactly the six
19
19
  * defects this module exists for. Every consumer of a store goes through the same property.
20
20
  */
21
+ import type { Guardrail } from "../guardrails/index.js";
21
22
  import type { RunEventLog } from "../core/events.js";
22
23
  import type { InteractionStore, MessageStore, RunStore, SkillStore, ThreadSummaryStore, UsageStore } from "../persistence/index.js";
23
24
  import type { PrincipalMemoryStore } from "../principal-memory/index.js";
@@ -56,6 +57,13 @@ export type RuntimeStores = {
56
57
  export type RuntimeServices = {
57
58
  readonly summarizer?: ThreadSummarizer;
58
59
  readonly mcpClient?: McpClient;
60
+ /**
61
+ * Ordered, and the order is the host's — REQ-046 (#205), AC-7.
62
+ *
63
+ * An array rather than a record, because composition order decides the outcome when two guardrails both
64
+ * redact, and a record's key order is an implementation detail that changes when somebody reformats a config.
65
+ */
66
+ readonly guardrails?: readonly Guardrail[];
59
67
  };
60
68
  export type CreateRuntimeInput = {
61
69
  readonly profile?: ProfileName;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * A token ceiling on a catalogue, and the rule that truncation is never quiet — REQ-045 (#204), task #210.
3
+ *
4
+ * The tool catalogue and the skill catalogue have the same problem: a compact entry per item, resident on every
5
+ * turn, linear in how many exist. #221 measured ~35 tokens per tool entry, so 200 tools is ~7,000 tokens before
6
+ * a word of the conversation. Two-tier loading bounded the *schemas* and left the entries.
7
+ *
8
+ * ## Truncation must be loud, and this is the whole guarantee
9
+ *
10
+ * A silently shortened catalogue is **indistinguishable from correct behaviour**. The model is not told an item
11
+ * was withheld; it simply never uses it, and the transcript reads as a run where the model chose not to. Nobody
12
+ * reviewing that run has anything to notice. So dropping is never quiet: the outcome names every dropped item,
13
+ * the engine turns that into a run event, and #210's AC-7 is a test that removing the event fails the build.
14
+ *
15
+ * In `core/` because both callers need it and neither owns it — and because a second copy for skills would drift
16
+ * from the first, which is the shape this repository keeps finding defects in.
17
+ */
18
+ export type TokenBudget = {
19
+ /** The ceiling. A catalogue at or under it is untouched and no event is emitted. */
20
+ readonly maxTokens: number;
21
+ };
22
+ export type BudgetOutcome<T> = {
23
+ readonly resident: readonly T[];
24
+ /** Names, in the order they were dropped. Named rather than counted: a count is not actionable. */
25
+ readonly dropped: readonly string[];
26
+ readonly residentTokens: number;
27
+ readonly budgetTokens: number;
28
+ /**
29
+ * True when the budget could not be met even after dropping everything droppable.
30
+ *
31
+ * Distinct from "it bound", because the two need different responses: a bound budget is the mechanism working,
32
+ * while an unmeetable one is a misconfiguration — the protected set alone does not fit, and a deployment that
33
+ * believes it capped its context has not.
34
+ */
35
+ readonly overBudget: boolean;
36
+ };
37
+ /**
38
+ * Keep what fits, in the order given, and name what did not.
39
+ *
40
+ * **Order is the caller's priority**, not a ranking invented here. For tools that is the order the host's
41
+ * providers were registered in, which is a deployment's own statement about what matters; inventing a relevance
42
+ * order would mean guessing at the model's next need, and `find_tools` is the honest answer to that.
43
+ *
44
+ * `protect` names what may never be dropped. Meta-tools are the reason it exists: dropping `find_tools` or
45
+ * `learn_tools` to save 35 tokens would remove the model's only route back to everything else, turning a budget
46
+ * into a permanent amputation.
47
+ */
48
+ export declare const applyTokenBudget: <T>(input: {
49
+ readonly items: readonly T[];
50
+ readonly budget: TokenBudget;
51
+ readonly tokensOf: (item: T) => number;
52
+ readonly nameOf: (item: T) => string;
53
+ readonly protect?: (item: T) => boolean;
54
+ }) => BudgetOutcome<T>;
55
+ //# sourceMappingURL=budget.d.ts.map