@inflexa-ai/harness 0.48.0 → 0.48.2
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/dist/contracts/chat-events.d.ts +40 -0
- package/dist/contracts/chat-frame.js +11 -0
- package/dist/contracts/index.d.ts +1 -1
- package/dist/contracts/message.d.ts +7 -1
- package/dist/contracts/schemas/chat-events.d.ts +319 -0
- package/dist/contracts/schemas/chat-events.js +29 -0
- package/dist/execution/run-synthesis.js +1 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +2 -1
- package/dist/input-sanitization.d.ts +30 -2
- package/dist/input-sanitization.js +187 -2
- package/dist/loop/run-loop.js +50 -5
- package/dist/loop/tool-adapter.d.ts +8 -0
- package/dist/loop/tool-adapter.js +75 -4
- package/dist/loop/tool-detail.d.ts +5 -1
- package/dist/loop/tool-detail.js +7 -3
- package/dist/loop/tool-value.d.ts +57 -0
- package/dist/loop/tool-value.js +249 -0
- package/dist/loop/types.d.ts +9 -2
- package/dist/memory/conversation-display-storage.js +4 -1
- package/dist/memory/count-tokens.js +29 -2
- package/dist/runtime/dbos.d.ts +6 -0
- package/dist/runtime/dbos.js +14 -2
- package/dist/sandbox/sandbox-step-translate.d.ts +5 -9
- package/dist/sandbox/sandbox-step-translate.js +5 -9
- package/dist/state/artifacts.d.ts +4 -1
- package/dist/state/artifacts.js +4 -1
- package/dist/state/index.d.ts +2 -2
- package/dist/state/index.js +1 -1
- package/dist/state/plans.d.ts +27 -0
- package/dist/state/plans.js +57 -3
- package/dist/tasks/data-profile-activity.d.ts +3 -7
- package/dist/tasks/data-profile-activity.js +1 -1
- package/dist/tasks/data-profile.js +3 -6
- package/dist/tools/ad-hoc-router.js +1 -0
- package/dist/tools/bio/alphafold-prediction.js +1 -0
- package/dist/tools/bio/chembl.js +1 -0
- package/dist/tools/bio/comptox.js +1 -0
- package/dist/tools/bio/drug-gene-interactions.js +1 -0
- package/dist/tools/bio/gene-disease-evidence.js +1 -0
- package/dist/tools/bio/gene-preclinical-profile.js +1 -0
- package/dist/tools/bio/lookup-annotation.js +1 -0
- package/dist/tools/bio/opentargets.js +1 -0
- package/dist/tools/bio/pubchem.js +1 -0
- package/dist/tools/bio/pubmed.js +1 -0
- package/dist/tools/bio/search-clinical-trials.js +1 -0
- package/dist/tools/bio/search-faers.js +1 -0
- package/dist/tools/bio/search-gene.js +1 -0
- package/dist/tools/bio/search-geo-datasets.js +1 -0
- package/dist/tools/bio/search-interactions.js +1 -0
- package/dist/tools/bio/search-patents.js +1 -0
- package/dist/tools/bio/search-protein.js +1 -0
- package/dist/tools/bio/search-regulatory-approvals.js +1 -0
- package/dist/tools/bio/target-safety.js +1 -0
- package/dist/tools/capability-data.js +5 -0
- package/dist/tools/define-tool.d.ts +18 -0
- package/dist/tools/define-tool.js +2 -0
- package/dist/tools/display/show-user.js +1 -0
- package/dist/tools/execute-analysis.js +4 -0
- package/dist/tools/knowledge/check.d.ts +47 -1
- package/dist/tools/knowledge/check.js +10 -1
- package/dist/tools/knowledge/client.d.ts +1 -1
- package/dist/tools/knowledge/recommend.js +4 -3
- package/dist/tools/knowledge/situation.d.ts +39 -1
- package/dist/tools/knowledge/situation.js +80 -8
- package/dist/tools/knowledge/skeleton.d.ts +7 -0
- package/dist/tools/knowledge/skeleton.js +88 -2
- package/dist/tools/knowledge/template.js +1 -0
- package/dist/tools/memory/update-working-memory.js +5 -0
- package/dist/tools/read-tool-output.js +4 -0
- package/dist/tools/report-authoring/authoring-tools.js +8 -0
- package/dist/tools/report-session/examine-page.js +5 -0
- package/dist/tools/report-session/list-artifacts.js +5 -0
- package/dist/tools/report-session/preview-report.js +1 -0
- package/dist/tools/report-session/record-version.js +1 -0
- package/dist/tools/report-session/script-tools.js +2 -0
- package/dist/tools/research/context7-docs.js +2 -0
- package/dist/tools/research/generate-analogy-report.js +3 -0
- package/dist/tools/research/generate-plan.js +6 -0
- package/dist/tools/research/inspect-data-profile.js +5 -0
- package/dist/tools/research/inspect-run.js +5 -0
- package/dist/tools/research/resolve-citation.js +1 -0
- package/dist/tools/research/search-arxiv.js +1 -0
- package/dist/tools/research/search-github-repos.js +1 -0
- package/dist/tools/research/search-semantic-scholar.js +1 -0
- package/dist/tools/research/web-fetch.js +1 -0
- package/dist/tools/research/web-search.js +1 -0
- package/dist/tools/sandbox/lease-tools.js +2 -0
- package/dist/tools/sandbox/link-packages.js +1 -0
- package/dist/tools/sandbox/list-available-packages.js +5 -0
- package/dist/tools/sandbox/list-available-refs.js +5 -0
- package/dist/tools/sandbox/report-blocker.js +1 -0
- package/dist/tools/sandbox/skills.js +2 -0
- package/dist/tools/sandbox/submit-file-metadata.js +1 -0
- package/dist/tools/script/file-tools.js +6 -0
- package/dist/tools/script/run-script.js +20 -1
- package/dist/tools/start-report-session.js +1 -0
- package/dist/tools/tool-view.d.ts +63 -0
- package/dist/tools/tool-view.js +68 -0
- package/dist/tools/workspace/edit-file.js +5 -0
- package/dist/tools/workspace/execute-command.js +5 -2
- package/dist/tools/workspace/file-stat.js +5 -0
- package/dist/tools/workspace/grep.js +4 -0
- package/dist/tools/workspace/list-files.js +5 -0
- package/dist/tools/workspace/read-file.js +4 -0
- package/dist/tools/workspace/scan-inputs.js +5 -0
- package/dist/tools/workspace/show-file.js +1 -0
- package/dist/tools/workspace/show-plan.js +1 -0
- package/dist/tools/workspace/workspace-search.js +5 -0
- package/dist/tools/workspace/write-file.js +7 -2
- package/dist/workflows/execute-analysis.js +2 -1
- package/dist/workflows/sandbox-step.js +3 -7
- package/package.json +1 -1
- package/dist/tools/detail-resolver.d.ts +0 -40
- package/dist/tools/detail-resolver.js +0 -44
|
@@ -3,9 +3,17 @@
|
|
|
3
3
|
* What runs is exactly what is declared here.
|
|
4
4
|
*
|
|
5
5
|
* `normalizeUnicode` and `redactSecrets` are applied once to the incoming user
|
|
6
|
-
* message by the chat route
|
|
7
|
-
*
|
|
6
|
+
* message by the chat route, and never to assistant messages or to the result
|
|
7
|
+
* that a tool gives the model. `stripNulCharacters` is the exception — it
|
|
8
|
+
* applies to tool results.
|
|
9
|
+
*
|
|
10
|
+
* `redactSecretForms`, `redactSecretFields`, and `redactSecretAssignments` are
|
|
11
|
+
* the value-only rules: only the shown values of a tool call (`loop/tool-value.ts`)
|
|
12
|
+
* get them. They are name-based rules that would mark prose and sequences in a
|
|
13
|
+
* user message. The model reads the unchanged result.
|
|
8
14
|
*/
|
|
15
|
+
import { isUnknownArray } from "./lib/unknown-array.js";
|
|
16
|
+
import { parseUnknownRecord } from "./lib/unknown-containers.js";
|
|
9
17
|
/**
|
|
10
18
|
* C0/C1 control characters and DEL, excluding `\t` (U+0009) and `\n`
|
|
11
19
|
* (U+000A) which are legitimate whitespace in user prose.
|
|
@@ -92,3 +100,180 @@ export function redactSecrets(text) {
|
|
|
92
100
|
}
|
|
93
101
|
return out;
|
|
94
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* The words that end the name of a field that holds a secret. A name is compared with the case, `_`, and `-` removed.
|
|
105
|
+
* The name must END with the word, not hold it: `max_tokens`, `tokenizer`, and `tokenCount` hold no secret. `pass` also
|
|
106
|
+
* ends a name such as `bypass`, which costs a shown fact only. `pwd` is not a word: `PWD` is the working directory.
|
|
107
|
+
*/
|
|
108
|
+
const SECRET_NAME_WORDS = [
|
|
109
|
+
"password",
|
|
110
|
+
"passwd",
|
|
111
|
+
"pass",
|
|
112
|
+
"secret",
|
|
113
|
+
"secretkey",
|
|
114
|
+
"token",
|
|
115
|
+
"apikey",
|
|
116
|
+
"accesskey",
|
|
117
|
+
"privatekey",
|
|
118
|
+
"credential",
|
|
119
|
+
"credentials",
|
|
120
|
+
"authorization",
|
|
121
|
+
"cookie",
|
|
122
|
+
];
|
|
123
|
+
function isSecretName(name) {
|
|
124
|
+
const folded = name.toLowerCase().replaceAll("_", "").replaceAll("-", "");
|
|
125
|
+
return SECRET_NAME_WORDS.some((word) => folded.endsWith(word));
|
|
126
|
+
}
|
|
127
|
+
const SECRET_FIELD_MARKER = "[REDACTED: Secret Field]";
|
|
128
|
+
const SECRET_ASSIGNMENT_MARKER = "[REDACTED: Secret Assignment]";
|
|
129
|
+
const PASSWORD_MARKER = "[REDACTED: Password]";
|
|
130
|
+
const SECRET_FLAG_MARKER = "[REDACTED: Secret Flag]";
|
|
131
|
+
// oxlint-disable-next-line eslint-js/no-restricted-syntax -- an argv element that is a long option, `--name`, with no value in it. The name is bounded, thus the test of an element is bounded.
|
|
132
|
+
const ARGV_FLAG = /^--([A-Za-z0-9][A-Za-z0-9_.-]{0,80})$/;
|
|
133
|
+
// oxlint-disable-next-line eslint-js/no-restricted-syntax -- the credential of an argv element after `-u` or `--user`: the user, a colon, and the password to the end of the element. The user holds no colon, so the match has one reading.
|
|
134
|
+
const ARGV_CREDENTIAL = /^([^:]*):[\s\S]*$/;
|
|
135
|
+
/**
|
|
136
|
+
* A copy of an array of strings in which the element after a `--name` element with a secret name is the redaction
|
|
137
|
+
* marker, and the element after `-u` or `--user` loses its password. A command line that is an array of words has no
|
|
138
|
+
* text in which the flag rules can see the flag and its value together.
|
|
139
|
+
*/
|
|
140
|
+
function redactArgvValues(array) {
|
|
141
|
+
return array.map((element, index) => {
|
|
142
|
+
const before = array[index - 1];
|
|
143
|
+
if (typeof element !== "string" || typeof before !== "string")
|
|
144
|
+
return redactSecretFields(element);
|
|
145
|
+
if (before === "-u" || before === "--user")
|
|
146
|
+
return element.replace(ARGV_CREDENTIAL, `$1:${PASSWORD_MARKER}`);
|
|
147
|
+
const flag = ARGV_FLAG.exec(before);
|
|
148
|
+
if (flag?.[1] !== undefined && isSecretName(flag[1]) && !element.startsWith("--"))
|
|
149
|
+
return SECRET_FLAG_MARKER;
|
|
150
|
+
return element;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* A copy of a JSON value in which the value of each object field with a secret name is the redaction marker. The
|
|
155
|
+
* walk goes to each depth, thus the `GITHUB_TOKEN` field of an `env` object is found. An array applies the rule of
|
|
156
|
+
* {@link redactArgvValues} to its strings. The rule belongs to the values of a tool call only. `redactSecrets` stays
|
|
157
|
+
* free of any name-based rule, because a user message holds prose and sequences that such a rule would mark.
|
|
158
|
+
*/
|
|
159
|
+
export function redactSecretFields(value) {
|
|
160
|
+
if (isUnknownArray(value))
|
|
161
|
+
return redactArgvValues(value);
|
|
162
|
+
const record = parseUnknownRecord(value);
|
|
163
|
+
if (record === undefined)
|
|
164
|
+
return value;
|
|
165
|
+
const out = {};
|
|
166
|
+
for (const [name, field] of Object.entries(record)) {
|
|
167
|
+
out[name] = isSecretName(name) && field !== null && field !== undefined ? SECRET_FIELD_MARKER : redactSecretFields(field);
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
// oxlint-disable-next-line eslint-js/no-restricted-syntax -- the shell and environment form NAME=value, with a quoted value that ends at its closing quote (an escaped quote inside a double-quoted value does not end it) or a value up to the next separator. A quote that does not close holds the rest of the text, because a cut text can end inside a secret. A name has at most 80 characters, thus a long run of name characters costs a bounded scan at each start. A value does not start with `=`, thus a comparison `token == x` stays whole, and a value that an earlier rule already replaced stays as it is.
|
|
172
|
+
const ASSIGNMENT = /([A-Za-z0-9_.-]{1,80})=(?!=|\[REDACTED: [A-Za-z ]{1,30}\])("(?:[^"\\]|\\.)*(?:"|\\?$)|'[^']*(?:'|$)|[^\s"'\\&;,]+)/g;
|
|
173
|
+
// oxlint-disable-next-line eslint-js/no-restricted-syntax -- a quoted field `"name": value`. The value is a string that ends at its closing quote (an escaped quote inside it does not end it, and a quote that does not close holds the rest of the text), or a number or a bare word that ends at a separator. A bare value does not start a nested object or array.
|
|
174
|
+
const QUOTED_FIELD = /"([^"\\\s]{1,80})"(\s*:\s*)(?:"((?:[^"\\]|\\.)*)(?:"|\\?$)|([^\s",}\]{[][^\s",}\]]*))/g;
|
|
175
|
+
/**
|
|
176
|
+
* Replace the value of each `NAME=value` assignment and of each quoted `"name": value` field whose name ends with a
|
|
177
|
+
* secret word. A caller runs it on a plain text or on one string of a JSON value, never on serialized JSON, because a
|
|
178
|
+
* JSON escape hides the quotes that the rules read. It runs on the text of a tool call value only. See
|
|
179
|
+
* {@link redactSecretFields}.
|
|
180
|
+
*/
|
|
181
|
+
export function redactSecretAssignments(text) {
|
|
182
|
+
return text
|
|
183
|
+
.replace(ASSIGNMENT, (whole, name) => (isSecretName(name) ? `${name}=${SECRET_ASSIGNMENT_MARKER}` : whole))
|
|
184
|
+
.replace(QUOTED_FIELD, (whole, name, colon, quoted) => {
|
|
185
|
+
if (!isSecretName(name))
|
|
186
|
+
return whole;
|
|
187
|
+
return quoted === undefined ? `"${name}"${colon}${SECRET_FIELD_MARKER}` : `"${name}"${colon}"${SECRET_FIELD_MARKER}"`;
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
const PRIVATE_KEY_MARKER = "[REDACTED: Private Key]";
|
|
191
|
+
const URL_PASSWORD_MARKER = "[REDACTED: URL Password]";
|
|
192
|
+
const SECRET_PAIR_MARKER = "[REDACTED: Secret Pair]";
|
|
193
|
+
const COOKIE_MARKER = "[REDACTED: Cookie]";
|
|
194
|
+
// oxlint-disable-next-line eslint-js/no-restricted-syntax -- the begin line and the end line of a PEM private-key block. The label is bounded and holds no newline, thus each line is read once.
|
|
195
|
+
const PRIVATE_KEY_LINE = /-----(BEGIN|END) [A-Z0-9 ]{0,40}PRIVATE KEY-----/g;
|
|
196
|
+
/**
|
|
197
|
+
* Replace each private-key block, from its begin line to its end line. A begin line with no end line hides the rest of
|
|
198
|
+
* the text. An end line with no begin line before it hides the text from the start of the text, or from the end of the
|
|
199
|
+
* block before it, because a window or a cut can start inside a block.
|
|
200
|
+
*/
|
|
201
|
+
function redactPrivateKeyBlocks(text) {
|
|
202
|
+
let out = "";
|
|
203
|
+
let copied = 0;
|
|
204
|
+
let blockStart = -1;
|
|
205
|
+
for (const line of text.matchAll(PRIVATE_KEY_LINE)) {
|
|
206
|
+
if (line[1] === "BEGIN") {
|
|
207
|
+
if (blockStart < 0)
|
|
208
|
+
blockStart = line.index;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
out += text.slice(copied, blockStart >= 0 ? blockStart : copied) + PRIVATE_KEY_MARKER;
|
|
212
|
+
copied = line.index + line[0].length;
|
|
213
|
+
blockStart = -1;
|
|
214
|
+
}
|
|
215
|
+
if (blockStart >= 0)
|
|
216
|
+
return out + text.slice(copied, blockStart) + PRIVATE_KEY_MARKER;
|
|
217
|
+
return out + text.slice(copied);
|
|
218
|
+
}
|
|
219
|
+
// oxlint-disable-next-line eslint-js/no-restricted-syntax -- a URL with a user and a password, `scheme://user:password@host`. The user holds no colon, so the match has one reading, and the password ends at the first `@`, `/`, or space.
|
|
220
|
+
const URL_PASSWORD = /\b([a-z][a-z0-9+.-]{0,30}:\/\/[^\s:@/?#"'`]{0,200}):[^\s@/"'`]{1,500}@/gi;
|
|
221
|
+
// oxlint-disable-next-line eslint-js/no-restricted-syntax -- the `-u user:password` and `--user user:password` options of curl and similar tools, with an unquoted, a double-quoted, or a single-quoted credential. The user holds no colon, so the match has one reading.
|
|
222
|
+
const USER_OPTION = /(^|\s)(-u|--user)(\s+|=)(?:"([^":\s]*):[^"]*"?|'([^':\s]*):[^']*'?|([^\s:"']*):\S*)/g;
|
|
223
|
+
// oxlint-disable-next-line eslint-js/no-restricted-syntax -- a `Cookie` or `Set-Cookie` header, whose value runs to the end of its line.
|
|
224
|
+
const COOKIE_HEADER = /(^|\s)((?:Set-)?Cookie)([ \t]*:[ \t]*)[^\r\n]+/gi;
|
|
225
|
+
// oxlint-disable-next-line eslint-js/no-restricted-syntax -- a command flag `--name value` or `--name=value`. The value is a quoted string (a quote that does not close holds the rest of the text) or the next word. A word that starts a flag is no value.
|
|
226
|
+
const COMMAND_FLAG = /(^|\s)--([A-Za-z0-9][A-Za-z0-9_.-]{0,80})(=|\s+)(?!--)("(?:[^"\\]|\\.)*(?:"|\\?$)|'[^']*(?:'|$)|\S+)/g;
|
|
227
|
+
// oxlint-disable-next-line eslint-js/no-restricted-syntax -- the start of a `name: value` pair of a header or of YAML: the name after a line start, a space, a comma, an opening bracket, or an opening quote, then its colon. The name has at most 80 characters, thus the scan at each start is bounded.
|
|
228
|
+
const PAIR_START = /(^|[\s,{("'])([A-Za-z][A-Za-z0-9_.-]{0,80})[ \t]*:[ \t]*/gm;
|
|
229
|
+
// oxlint-disable-next-line eslint-js/no-restricted-syntax -- the value of a `name: value` pair, read at the end of its start: a quoted string, or the text up to the end of the line or the next quote.
|
|
230
|
+
const PAIR_VALUE = /"(?:[^"\\\r\n]|\\.)*"?|'[^'\r\n]*'?|[^\r\n"']+/y;
|
|
231
|
+
/**
|
|
232
|
+
* Replace the value of each `name: value` pair whose name ends with a secret word. The scan goes on after the colon of
|
|
233
|
+
* a name that holds no secret, thus a secret pair later on the same line is found. Each step moves forward, thus the
|
|
234
|
+
* cost is linear.
|
|
235
|
+
*/
|
|
236
|
+
function redactNameValuePairs(text) {
|
|
237
|
+
// The patterns carry `lastIndex`, and this function runs to its end with no await, thus no call reads it between two steps of another.
|
|
238
|
+
const start = PAIR_START;
|
|
239
|
+
const value = PAIR_VALUE;
|
|
240
|
+
start.lastIndex = 0;
|
|
241
|
+
let out = "";
|
|
242
|
+
let copied = 0;
|
|
243
|
+
for (let pair = start.exec(text); pair !== null; pair = start.exec(text)) {
|
|
244
|
+
const name = pair[2] ?? "";
|
|
245
|
+
const end = pair.index + pair[0].length;
|
|
246
|
+
if (!isSecretName(name)) {
|
|
247
|
+
// The blanks after the colon stay available as the lead of the next name.
|
|
248
|
+
start.lastIndex = end - (pair[0].length - pair[0].trimEnd().length);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
value.lastIndex = end;
|
|
252
|
+
const shown = value.exec(text);
|
|
253
|
+
if (shown === null)
|
|
254
|
+
continue;
|
|
255
|
+
out += `${text.slice(copied, end)}${SECRET_PAIR_MARKER}`;
|
|
256
|
+
copied = end + shown[0].length;
|
|
257
|
+
start.lastIndex = copied;
|
|
258
|
+
}
|
|
259
|
+
return out + text.slice(copied);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Replace the secret that each of these forms holds: a private-key block, the password of a URL, the password of a
|
|
263
|
+
* `-u user:password` option, the value of a `Cookie` header, the value of a `--name value` flag, the value of a
|
|
264
|
+
* `name: value` pair, and each form of {@link redactSecretAssignments}. A name must end with a secret word.
|
|
265
|
+
* It covers these forms only: a text can hold a secret in a different form. It runs on the text of a tool call value
|
|
266
|
+
* only, on a plain text or on one string of a JSON value.
|
|
267
|
+
*/
|
|
268
|
+
export function redactSecretForms(text) {
|
|
269
|
+
const withoutKeys = redactPrivateKeyBlocks(text);
|
|
270
|
+
const withoutUrlPasswords = withoutKeys.replace(URL_PASSWORD, `$1:${URL_PASSWORD_MARKER}@`);
|
|
271
|
+
const withoutUserOptions = withoutUrlPasswords.replace(USER_OPTION, (_whole, lead, option, joint, double, single, bare) => {
|
|
272
|
+
const quote = double !== undefined ? '"' : single !== undefined ? "'" : "";
|
|
273
|
+
const user = double ?? single ?? bare ?? "";
|
|
274
|
+
return `${lead}${option}${joint}${quote}${user}:${PASSWORD_MARKER}${quote}`;
|
|
275
|
+
});
|
|
276
|
+
const withoutCookies = withoutUserOptions.replace(COOKIE_HEADER, `$1$2$3${COOKIE_MARKER}`);
|
|
277
|
+
const withoutFlags = withoutCookies.replace(COMMAND_FLAG, (whole, lead, name, joint) => isSecretName(name) ? `${lead}--${name}${joint}${SECRET_FLAG_MARKER}` : whole);
|
|
278
|
+
return redactSecretAssignments(redactNameValuePairs(withoutFlags));
|
|
279
|
+
}
|
package/dist/loop/run-loop.js
CHANGED
|
@@ -34,8 +34,10 @@ import { createCallPolicy } from "./call-policy.js";
|
|
|
34
34
|
import { createRunScriptTool } from "../tools/script/run-script.js";
|
|
35
35
|
import { COMPACTION_MAX_REQUESTS } from "./compaction.js";
|
|
36
36
|
import { addChatUsage, hasReportedUsage, recordAgentRun } from "./metrics.js";
|
|
37
|
-
import { adaptTools, deniedReason, foldCallRecords, isFatalToolError, modelViewOf, recordedInputOf, ToolCallOutputSchema, } from "./tool-adapter.js";
|
|
37
|
+
import { adaptTools, deniedReason, foldCallRecords, isFatalToolError, modelViewOf, recordedInputOf, ToolCallOutputSchema, viewInputValue, } from "./tool-adapter.js";
|
|
38
|
+
import { computeDetail } from "./tool-detail.js";
|
|
38
39
|
import { READ_TOOL_OUTPUT_TOOL_ID } from "./tool-output.js";
|
|
40
|
+
import { textToolValue } from "./tool-value.js";
|
|
39
41
|
/** The reason of a call that asks for approval in a loop where no surface can answer. */
|
|
40
42
|
const NO_APPROVER_REASON = "This action needs the approval of the user, and no one can answer here. Thus it did not run.";
|
|
41
43
|
/** The harness request after a reply that a failed request cut. The model reads its partial reply, and continues it. */
|
|
@@ -514,6 +516,16 @@ async function runSeries(agent, opts, calls, log, target, spec) {
|
|
|
514
516
|
const decision = await opts.approve(request, { toolCallId: toolCall.toolCallId, toolName: toolCall.toolName });
|
|
515
517
|
return decision.kind === "approved" ? undefined : deniedReason(decision.feedback);
|
|
516
518
|
};
|
|
519
|
+
// A refused call and a denied call do not run, thus the loop gives their events the detail and the view input of the tool.
|
|
520
|
+
// Only a root loop shows values, as the tool adapter does.
|
|
521
|
+
const shownRoot = source.callPath.length <= 1;
|
|
522
|
+
const callShownOf = (toolCall) => {
|
|
523
|
+
const tool = toolsById.get(toolCall.toolName);
|
|
524
|
+
if (tool === undefined)
|
|
525
|
+
return { detail: {}, input: undefined };
|
|
526
|
+
const detail = computeDetail(tool, toolCall.input, log);
|
|
527
|
+
return { detail: detail === undefined ? {} : { detail }, input: shownRoot ? viewInputValue(tool, toolCall.input, log) : undefined };
|
|
528
|
+
};
|
|
517
529
|
const seriesSteps = [];
|
|
518
530
|
let open = openStep();
|
|
519
531
|
let failure;
|
|
@@ -548,8 +560,25 @@ async function runSeries(agent, opts, calls, log, target, spec) {
|
|
|
548
560
|
const refusal = calls.refusalOf({ toolName: toolCall.toolName, input: toolCall.input }, spec.mask);
|
|
549
561
|
if (refusal !== undefined) {
|
|
550
562
|
// A refused call does not run, thus its two events come from here.
|
|
551
|
-
|
|
552
|
-
await opts.emit({
|
|
563
|
+
const shown = callShownOf(toolCall);
|
|
564
|
+
await opts.emit({
|
|
565
|
+
type: "tool-started",
|
|
566
|
+
source,
|
|
567
|
+
toolUseId: toolCall.toolCallId,
|
|
568
|
+
name: toolCall.toolName,
|
|
569
|
+
...shown.detail,
|
|
570
|
+
input: shown.input,
|
|
571
|
+
});
|
|
572
|
+
await opts.emit({
|
|
573
|
+
type: "tool-finished",
|
|
574
|
+
source,
|
|
575
|
+
toolUseId: toolCall.toolCallId,
|
|
576
|
+
name: toolCall.toolName,
|
|
577
|
+
outcome: "error",
|
|
578
|
+
...shown.detail,
|
|
579
|
+
input: shown.input,
|
|
580
|
+
error: shownRoot ? textToolValue(refusal) : undefined,
|
|
581
|
+
});
|
|
553
582
|
return { type: "denied", reason: refusal };
|
|
554
583
|
}
|
|
555
584
|
let denial;
|
|
@@ -565,8 +594,24 @@ async function runSeries(agent, opts, calls, log, target, spec) {
|
|
|
565
594
|
if (denial === undefined)
|
|
566
595
|
return undefined;
|
|
567
596
|
deniedByTheUser.set(toolCall.toolCallId, denial);
|
|
568
|
-
|
|
569
|
-
await opts.emit({
|
|
597
|
+
const shown = callShownOf(toolCall);
|
|
598
|
+
await opts.emit({
|
|
599
|
+
type: "tool-started",
|
|
600
|
+
source,
|
|
601
|
+
toolUseId: toolCall.toolCallId,
|
|
602
|
+
name: toolCall.toolName,
|
|
603
|
+
...shown.detail,
|
|
604
|
+
input: shown.input,
|
|
605
|
+
});
|
|
606
|
+
await opts.emit({
|
|
607
|
+
type: "tool-finished",
|
|
608
|
+
source,
|
|
609
|
+
toolUseId: toolCall.toolCallId,
|
|
610
|
+
name: toolCall.toolName,
|
|
611
|
+
outcome: "denied",
|
|
612
|
+
...shown.detail,
|
|
613
|
+
input: shown.input,
|
|
614
|
+
});
|
|
570
615
|
return { type: "denied", reason: denial };
|
|
571
616
|
},
|
|
572
617
|
stopWhen: () => stops(),
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { type ToolSet, type UIMessageStreamWriter } from "ai";
|
|
18
18
|
import type { ToolResultOutput } from "@ai-sdk/provider-utils";
|
|
19
19
|
import { z } from "zod";
|
|
20
|
+
import type { ToolCallValue } from "../contracts/chat-events.js";
|
|
20
21
|
import type { AgentSession } from "../auth/types.js";
|
|
21
22
|
import type { Logger } from "../lib/logger.js";
|
|
22
23
|
import type { ProviderCapabilities } from "../providers/types.js";
|
|
@@ -94,5 +95,12 @@ export declare function foldCallRecords(tools: readonly Tool[], calls: readonly
|
|
|
94
95
|
readonly toolCallId: string;
|
|
95
96
|
readonly output?: ToolCallOutput;
|
|
96
97
|
}[], log: Logger): void;
|
|
98
|
+
/**
|
|
99
|
+
* The shown input of a call: the `input` member of the view of the tool, run on the input after the parse with the input
|
|
100
|
+
* schema. The parse is inside the guard, because a schema with an async refinement throws on a synchronous parse. A
|
|
101
|
+
* tool with no `input` member, an input that fails the parse, a member that throws, and a value that breaks a rule give
|
|
102
|
+
* no value, and each of the last two logs one warning. A value never fails a call.
|
|
103
|
+
*/
|
|
104
|
+
export declare function viewInputValue(tool: Tool, rawInput: unknown, log: Logger): ToolCallValue | undefined;
|
|
97
105
|
/** The prose of a rejected approval is the only account that the model gets of the denial. */
|
|
98
106
|
export declare function deniedReason(feedback: string | undefined): string;
|
|
@@ -29,6 +29,7 @@ import { isToolError, readToolCallRecord, readToolResultImages, } from "../tools
|
|
|
29
29
|
import { recordKeyFor } from "./accounting.js";
|
|
30
30
|
import { computeDetail, computeResultDetail } from "./tool-detail.js";
|
|
31
31
|
import { excerptOf, keptTextOf, TOOL_RESULT_CAP, toolOutputRef } from "./tool-output.js";
|
|
32
|
+
import { errorToolValue, makeToolCallValue } from "./tool-value.js";
|
|
32
33
|
const ToolResultImageSchema = z.object({ base64: z.string(), mediaType: z.string() });
|
|
33
34
|
const JsonSchema = z.json();
|
|
34
35
|
/**
|
|
@@ -157,9 +158,19 @@ async function runToolCall(tool, input, options, run) {
|
|
|
157
158
|
const startedAt = performance.now();
|
|
158
159
|
const { toolCallId } = options;
|
|
159
160
|
const detail = computeDetail(tool, input, run.log);
|
|
161
|
+
// Only a root call shows values: no reader shows the values of a sub-agent call or of a nested call of a script.
|
|
162
|
+
const showsValues = run.source.callPath.length <= 1;
|
|
160
163
|
// The emit and the call start before the first await: a call that runs in the body of a workflow takes its
|
|
161
164
|
// function ids in the order of the calls, thus a replay gives each call its own child.
|
|
162
|
-
const
|
|
165
|
+
const shownInput = showsValues ? viewInputValue(tool, input, run.log) : undefined;
|
|
166
|
+
const started = run.emit({
|
|
167
|
+
type: "tool-started",
|
|
168
|
+
source: run.source,
|
|
169
|
+
toolUseId: toolCallId,
|
|
170
|
+
name: tool.id,
|
|
171
|
+
...detailField(detail),
|
|
172
|
+
input: shownInput,
|
|
173
|
+
});
|
|
163
174
|
const ctx = {
|
|
164
175
|
invocationId: toolCallId,
|
|
165
176
|
session: run.session,
|
|
@@ -168,7 +179,9 @@ async function runToolCall(tool, input, options, run) {
|
|
|
168
179
|
turnUsage: run.turnUsage,
|
|
169
180
|
};
|
|
170
181
|
// One wait reads the two, thus a failed emit leaves no rejection of the call unread.
|
|
171
|
-
const [, settled] = await Promise.all([started, settleCall(tool, input, ctx, run)]);
|
|
182
|
+
const [, settled] = await Promise.all([started, settleCall(tool, input, ctx, run, showsValues)]);
|
|
183
|
+
// The values come from the output before the cut for the model, thus a host shows the start and the end of the whole text.
|
|
184
|
+
const shownError = showsValues && settled.output.outcome === "error" ? viewErrorValue(tool, settled.output.text) : undefined;
|
|
172
185
|
const output = await cutLongOutput(settled.output, tool.id, toolCallId, run);
|
|
173
186
|
await run.emit({
|
|
174
187
|
type: "tool-finished",
|
|
@@ -178,10 +191,13 @@ async function runToolCall(tool, input, options, run) {
|
|
|
178
191
|
outcome: output.outcome,
|
|
179
192
|
...detailField(settled.detail ?? detail),
|
|
180
193
|
durationMs: Math.round(performance.now() - startedAt),
|
|
194
|
+
input: shownInput,
|
|
195
|
+
result: settled.result,
|
|
196
|
+
error: shownError,
|
|
181
197
|
});
|
|
182
198
|
return output;
|
|
183
199
|
}
|
|
184
|
-
async function settleCall(tool, input, ctx, run) {
|
|
200
|
+
async function settleCall(tool, input, ctx, run, showsValues) {
|
|
185
201
|
const parsed = tool.inputSchema.safeParse(input);
|
|
186
202
|
let data;
|
|
187
203
|
if (parsed.success) {
|
|
@@ -209,7 +225,11 @@ async function settleCall(tool, input, ctx, run) {
|
|
|
209
225
|
}
|
|
210
226
|
// The one place a result description runs: the ok value in hand, the
|
|
211
227
|
// input already validated, and the guard inside the compute.
|
|
212
|
-
return {
|
|
228
|
+
return {
|
|
229
|
+
output: okOutput(tool.id, result.value, run),
|
|
230
|
+
detail: computeResultDetail(tool, data, result.value, run.log),
|
|
231
|
+
result: showsValues ? viewResultValue(tool, data, result.value, run.log) : undefined,
|
|
232
|
+
};
|
|
213
233
|
}
|
|
214
234
|
catch (err) {
|
|
215
235
|
if (isFatalToolError(err))
|
|
@@ -218,6 +238,57 @@ async function settleCall(tool, input, ctx, run) {
|
|
|
218
238
|
return { output: errorOutput(toolErrorContent(err)) };
|
|
219
239
|
}
|
|
220
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* The value of the output of a view member, or `undefined`. A value that breaks a rule or does not serialize gives no
|
|
243
|
+
* value and logs one warning with the reason, as a member that throws does. A member that gives `undefined` gives no
|
|
244
|
+
* value and no warning.
|
|
245
|
+
*/
|
|
246
|
+
function shownValue(tool, member, output, log) {
|
|
247
|
+
const made = makeToolCallValue(output);
|
|
248
|
+
if (made.isErr()) {
|
|
249
|
+
log.warn(`the ${member} view of a tool gave a value that cannot show`, { tool: tool.id, reason: made.error });
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
return made.value;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The shown input of a call: the `input` member of the view of the tool, run on the input after the parse with the input
|
|
256
|
+
* schema. The parse is inside the guard, because a schema with an async refinement throws on a synchronous parse. A
|
|
257
|
+
* tool with no `input` member, an input that fails the parse, a member that throws, and a value that breaks a rule give
|
|
258
|
+
* no value, and each of the last two logs one warning. A value never fails a call.
|
|
259
|
+
*/
|
|
260
|
+
export function viewInputValue(tool, rawInput, log) {
|
|
261
|
+
const view = tool.view;
|
|
262
|
+
if (typeof view?.input !== "function")
|
|
263
|
+
return undefined;
|
|
264
|
+
try {
|
|
265
|
+
const parsed = recordedInputOf(tool.inputSchema, rawInput);
|
|
266
|
+
if (parsed === undefined)
|
|
267
|
+
return undefined;
|
|
268
|
+
return shownValue(tool, "input", view.input(parsed), log);
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
log.warn("the input view of a tool failed", { tool: tool.id, ...log.errorFields(err) });
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/** The shown result of a call that ended `ok`: the `result` member of the view, run on the parsed input and the ok value. The guard is that of {@link viewInputValue}. */
|
|
276
|
+
function viewResultValue(tool, parsedInput, result, log) {
|
|
277
|
+
const view = tool.view;
|
|
278
|
+
if (typeof view?.result !== "function")
|
|
279
|
+
return undefined;
|
|
280
|
+
try {
|
|
281
|
+
return shownValue(tool, "result", view.result(parsedInput, result), log);
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
log.warn("the result view of a tool failed", { tool: tool.id, ...log.errorFields(err) });
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/** The shown error of a call that ended `error`, when the view declares `error`. `text` is the error text that the model reads, before the cut. */
|
|
289
|
+
function viewErrorValue(tool, text) {
|
|
290
|
+
return tool.view?.error === true ? errorToolValue(text) : undefined;
|
|
291
|
+
}
|
|
221
292
|
/**
|
|
222
293
|
* The ok output of a value. The JSON data loses each NUL, and the pictures leave
|
|
223
294
|
* it. A wire that carries no picture in a tool result gets the text alone, and
|
|
@@ -71,13 +71,17 @@ export declare function capCodePoints(text: string, max: number): string;
|
|
|
71
71
|
* Normalize whatever a hook returned into an emittable detail, or `undefined`.
|
|
72
72
|
*
|
|
73
73
|
* In order: reject anything that is not a usable string, strip control
|
|
74
|
-
* characters,
|
|
74
|
+
* characters, redact the text forms of the tool call values (a flag, an
|
|
75
|
+
* assignment, a pair, a cookie, a URL password), collapse to one line, redact
|
|
76
|
+
* the prefixed secret formats, then cap the length.
|
|
75
77
|
*
|
|
76
78
|
* The control-character strip is the harness's existing `normalizeUnicode`,
|
|
77
79
|
* which is also what the chat route applies to user prose — a detail is
|
|
78
80
|
* rendered by a host, so a terminal escape sequence in it is an injection.
|
|
79
81
|
* Redaction runs before the cap, so a secret cannot survive by being cut in
|
|
80
82
|
* half, and after the strip, so an embedded control byte cannot break a pattern.
|
|
83
|
+
* The text forms run before the collapse, because a form such as a cookie header
|
|
84
|
+
* or a `name: value` pair ends at the end of its line.
|
|
81
85
|
*/
|
|
82
86
|
export declare function normalizeDetail(raw: unknown): ToolCallDetail | undefined;
|
|
83
87
|
/**
|
package/dist/loop/tool-detail.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* each get the line breaks, the control characters, the secret redaction, and
|
|
17
17
|
* the length cap slightly wrong. Here it is one auditable path.
|
|
18
18
|
*/
|
|
19
|
-
import { normalizeUnicode, redactSecrets } from "../input-sanitization.js";
|
|
19
|
+
import { normalizeUnicode, redactSecretForms, redactSecrets } from "../input-sanitization.js";
|
|
20
20
|
import { collapseWhitespace } from "../lib/text.js";
|
|
21
21
|
/**
|
|
22
22
|
* The length cap, in characters. A full workspace path with a line range fits
|
|
@@ -75,13 +75,17 @@ export function capCodePoints(text, max) {
|
|
|
75
75
|
* Normalize whatever a hook returned into an emittable detail, or `undefined`.
|
|
76
76
|
*
|
|
77
77
|
* In order: reject anything that is not a usable string, strip control
|
|
78
|
-
* characters,
|
|
78
|
+
* characters, redact the text forms of the tool call values (a flag, an
|
|
79
|
+
* assignment, a pair, a cookie, a URL password), collapse to one line, redact
|
|
80
|
+
* the prefixed secret formats, then cap the length.
|
|
79
81
|
*
|
|
80
82
|
* The control-character strip is the harness's existing `normalizeUnicode`,
|
|
81
83
|
* which is also what the chat route applies to user prose — a detail is
|
|
82
84
|
* rendered by a host, so a terminal escape sequence in it is an injection.
|
|
83
85
|
* Redaction runs before the cap, so a secret cannot survive by being cut in
|
|
84
86
|
* half, and after the strip, so an embedded control byte cannot break a pattern.
|
|
87
|
+
* The text forms run before the collapse, because a form such as a cookie header
|
|
88
|
+
* or a `name: value` pair ends at the end of its line.
|
|
85
89
|
*/
|
|
86
90
|
export function normalizeDetail(raw) {
|
|
87
91
|
if (typeof raw !== "string" || raw.length === 0)
|
|
@@ -89,7 +93,7 @@ export function normalizeDetail(raw) {
|
|
|
89
93
|
// `normalizeUnicode` leaves tab, newline and carriage return in place (they
|
|
90
94
|
// are legitimate whitespace in prose); the collapse below is what turns them
|
|
91
95
|
// into the single line this contract promises.
|
|
92
|
-
const oneLine = collapseWhitespace(normalizeUnicode(raw));
|
|
96
|
+
const oneLine = collapseWhitespace(redactSecretForms(normalizeUnicode(raw)));
|
|
93
97
|
if (oneLine.length === 0)
|
|
94
98
|
return undefined;
|
|
95
99
|
const capped = capCodePoints(redactSecrets(oneLine), DETAIL_MAX_LENGTH);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shown values of a tool call: its input, its result, and its error.
|
|
3
|
+
*
|
|
4
|
+
* The emit site makes each value one time, and a host only shows it. A tool declares what it shows through its view
|
|
5
|
+
* (`tools/tool-view.ts`), and {@link makeToolCallValue} turns the output of a view member into a value with a kind.
|
|
6
|
+
* Each leaf of a value gets the steps below on its own. The steps run in this order, because each step
|
|
7
|
+
* protects the next one: the control characters go first, thus a control byte inside a secret cannot stop a pattern.
|
|
8
|
+
* The secrets go before the cut, thus a cut cannot divide a secret and let one half pass. The shown text can differ
|
|
9
|
+
* from the text that the model reads.
|
|
10
|
+
*/
|
|
11
|
+
import type { Result } from "neverthrow";
|
|
12
|
+
import type { ToolCallLeaf, ToolCallValue } from "../contracts/chat-events.js";
|
|
13
|
+
import type { EmitEvent } from "./types.js";
|
|
14
|
+
import type { ChatStreamEvent } from "../providers/types.js";
|
|
15
|
+
/** A text longer than this is cut. The unit is the UTF-16 code unit. */
|
|
16
|
+
export declare const TOOL_VALUE_CAP: number;
|
|
17
|
+
/** The start of a cut text holds the shape of a value. */
|
|
18
|
+
export declare const TOOL_VALUE_HEAD_CHARS: number;
|
|
19
|
+
/** The end of a cut text holds the errors of a command, thus it gets more room than the start. */
|
|
20
|
+
export declare const TOOL_VALUE_TAIL_CHARS: number;
|
|
21
|
+
/**
|
|
22
|
+
* The text past each shown part that the rules also read. A secret at the edge of a shown part is whole in its window
|
|
23
|
+
* when it is shorter than this margin, thus the rules see it as they see it in the whole text.
|
|
24
|
+
*/
|
|
25
|
+
export declare const TOOL_VALUE_CLEAN_MARGIN: number;
|
|
26
|
+
/** The leaf of a plain text: control characters out, each secret out, then the cut. */
|
|
27
|
+
export declare function textToolValue(text: string): ToolCallLeaf;
|
|
28
|
+
/** The leaf of source code. The rules and the cut are those of {@link textToolValue}. `language` is checked with the whole value. */
|
|
29
|
+
export declare function codeToolValue(language: string, text: string): ToolCallLeaf;
|
|
30
|
+
/**
|
|
31
|
+
* The leaf of a JSON value. A string shows as a JSON string, and each other value shows as its JSON text.
|
|
32
|
+
* The field-name rule runs first, then the text rules on each string, then the serialization, then the cut: a text
|
|
33
|
+
* rule never meets a JSON escape, which would hide the end of a quoted secret.
|
|
34
|
+
* A long JSON text gets the rules on a copy of each end of the value only, and its marker counts the JSON text of the
|
|
35
|
+
* original value. Only the serialization of the whole value grows with its size, and the model path pays it already.
|
|
36
|
+
* A value with no JSON text, and a value that does not serialize, for example a cycle, give no leaf: a value is a
|
|
37
|
+
* diagnostic and never fails a call.
|
|
38
|
+
*/
|
|
39
|
+
export declare function jsonToolValue(value: unknown): ToolCallLeaf | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* The leaf of an error text. The adapter writes the error of a tool as the JSON text `{ "error": message, ... }`, and
|
|
42
|
+
* the leaf shows the message. Each other error text, for example a refusal of the call policy, shows whole.
|
|
43
|
+
*/
|
|
44
|
+
export declare function errorToolValue(text: string): ToolCallLeaf;
|
|
45
|
+
/**
|
|
46
|
+
* The value of the output of a view member: a helper object gives its kind, a string gives a `text` leaf, and each other
|
|
47
|
+
* value gives a `json` leaf. Each leaf gets the cleaning and the cap of its kind. The result holds no value for an output
|
|
48
|
+
* that is absent (silent), and for a `fields` value with no field. It holds the reason for an output that cannot show: a
|
|
49
|
+
* value with no JSON text, a value that does not serialize, and a value that breaks a rule of the kinds (more fields than
|
|
50
|
+
* the limit, a nested `fields` value, a bad field name, a bad language). The caller logs the reason. It never throws.
|
|
51
|
+
*/
|
|
52
|
+
export declare function makeToolCallValue(output: unknown): Result<ToolCallValue | undefined, string>;
|
|
53
|
+
/**
|
|
54
|
+
* The event without the three values. A forwarder of loop events to the stream of a run calls it, because no reader
|
|
55
|
+
* of that stream shows a value, and a value costs bytes on each event.
|
|
56
|
+
*/
|
|
57
|
+
export declare function withoutToolValues(event: EmitEvent | ChatStreamEvent): EmitEvent | ChatStreamEvent;
|