@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.
- package/README.md +59 -277
- package/dist/adapters/embeddings/openai.d.ts +45 -0
- package/dist/adapters/embeddings/openai.js +109 -0
- package/dist/agents/agent.d.ts +22 -1
- package/dist/agents/agent.js +97 -11
- package/dist/agents/engine.d.ts +28 -0
- package/dist/agents/engine.js +194 -8
- package/dist/capabilities/index.d.ts +5 -1
- package/dist/capabilities/index.js +23 -0
- package/dist/capabilities/runtime.d.ts +8 -0
- package/dist/core/budget.d.ts +55 -0
- package/dist/core/budget.js +56 -0
- package/dist/core/content-parts.d.ts +8 -0
- package/dist/core/events.d.ts +68 -2
- package/dist/core/events.js +2 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/documents/index.d.ts +14 -0
- package/dist/documents/parsers/text.d.ts +16 -0
- package/dist/documents/parsers/text.js +54 -2
- package/dist/entries/guardrails.d.ts +14 -0
- package/dist/entries/guardrails.js +14 -0
- package/dist/entries/knowledge.d.ts +9 -0
- package/dist/entries/knowledge.js +8 -0
- package/dist/graphql/resolvers.d.ts +4 -0
- package/dist/graphql/resolvers.js +6 -0
- package/dist/graphql/schema.d.ts +1 -1
- package/dist/graphql/schema.js +44 -0
- package/dist/guardrails/index.d.ts +115 -0
- package/dist/guardrails/index.js +108 -0
- package/dist/guardrails/moderation.d.ts +53 -0
- package/dist/guardrails/moderation.js +75 -0
- package/dist/guardrails/pii.d.ts +75 -0
- package/dist/guardrails/pii.js +193 -0
- package/dist/knowledge/index.d.ts +1 -0
- package/dist/knowledge/index.js +1 -0
- package/dist/knowledge/navigate.d.ts +89 -0
- package/dist/knowledge/navigate.js +107 -0
- package/dist/knowledge/retrieval.d.ts +73 -5
- package/dist/knowledge/retrieval.js +82 -28
- package/dist/models/streaming.d.ts +22 -1
- package/dist/models/streaming.js +5 -1
- package/dist/security/checklist.js +9 -0
- package/dist/security/findings.js +18 -9
- package/dist/skills/catalogue.d.ts +49 -0
- package/dist/skills/catalogue.js +61 -0
- package/dist/skills/index.d.ts +1 -0
- package/dist/skills/index.js +1 -0
- package/dist/telemetry/spans.js +12 -0
- package/dist/toolkit/files.d.ts +125 -0
- package/dist/toolkit/files.js +320 -0
- package/dist/toolkit/index.d.ts +4 -0
- package/dist/toolkit/index.js +2 -0
- package/dist/toolkit/sandbox.d.ts +119 -0
- package/dist/toolkit/sandbox.js +239 -0
- package/dist/toolkit/web.d.ts +13 -0
- package/dist/toolkit/web.js +7 -1
- package/dist/tools/budget.d.ts +28 -0
- package/dist/tools/budget.js +35 -0
- package/dist/tools/credentials.d.ts +57 -0
- package/dist/tools/credentials.js +54 -0
- package/dist/tools/define.d.ts +31 -0
- package/dist/tools/define.js +23 -0
- package/dist/tools/find.d.ts +109 -0
- package/dist/tools/find.js +210 -0
- package/dist/tools/index.d.ts +14 -2
- package/dist/tools/index.js +4 -0
- package/dist/tools/library/fs.d.ts +24 -0
- package/dist/tools/library/fs.js +102 -0
- package/dist/tools/library/index.d.ts +29 -2
- package/dist/tools/library/index.js +40 -0
- package/dist/tools/library/shell.d.ts +45 -0
- package/dist/tools/library/shell.js +70 -0
- package/dist/tools/meta-tools.js +8 -0
- package/dist/tools/registry.d.ts +113 -0
- package/dist/tools/registry.js +180 -4
- package/package.json +5 -1
package/dist/agents/agent.js
CHANGED
|
@@ -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({
|
|
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
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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:
|
|
132
|
-
description:
|
|
133
|
-
|
|
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:
|
|
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;
|
package/dist/agents/engine.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/agents/engine.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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 {
|
|
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
|