@jskit-ai/agent 0.0.0-stage → 0.1.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 +317 -2
- package/docs/deepseek-harness.md +1289 -0
- package/package.json +42 -4
- package/src/server/callbacks.js +13 -0
- package/src/server/checks.js +101 -0
- package/src/server/context.js +84 -0
- package/src/server/index.js +11 -0
- package/src/server/loop.js +192 -0
- package/src/server/model.js +43 -0
- package/src/server/schema.js +88 -0
- package/src/server/summaries.js +130 -0
- package/src/server/text.js +52 -0
- package/src/server/tool.js +30 -0
- package/src/server/turn.js +38 -0
- package/test/checks.test.js +45 -0
- package/test/context.test.js +37 -0
- package/test/loop.test.js +39 -0
- package/test/summaries.test.js +38 -0
- package/test/text.test.js +34 -0
- package/test/turn.test.js +47 -0
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "node --test --test-concurrency=1"
|
|
7
|
+
},
|
|
8
|
+
"exports": {
|
|
9
|
+
"./server": "./src/server/index.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@ai-sdk/anthropic": "^4.0.93",
|
|
13
|
+
"@ai-sdk/deepseek": "^3.0.72",
|
|
14
|
+
"ai": "^7.0.152",
|
|
15
|
+
"zod": "^4.6.7"
|
|
16
|
+
},
|
|
17
|
+
"description": "A working agent loop on the AI SDK: reasoning on every step, retries, tools, room inside a long turn, checks on every reply and summaries for a 1M-token window. No tables.",
|
|
18
|
+
"jskit": {
|
|
19
|
+
"kind": "runtime",
|
|
20
|
+
"capabilities": {
|
|
21
|
+
"provides": [],
|
|
22
|
+
"requires": []
|
|
23
|
+
},
|
|
24
|
+
"runtime": {
|
|
25
|
+
"server": {
|
|
26
|
+
"providers": []
|
|
27
|
+
},
|
|
28
|
+
"client": {
|
|
29
|
+
"providers": []
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"metadata": {
|
|
33
|
+
"apiSummary": {
|
|
34
|
+
"surfaces": [
|
|
35
|
+
{
|
|
36
|
+
"subpath": "./server",
|
|
37
|
+
"summary": "The agent loop, its model calls, tool schemas, context limits, reply checks and table-free summaries."
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"capabilities": []
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refuses a callback option that was given but is not a function, naming the entry and the option.
|
|
3
|
+
* An option left out has already taken its named default. A wrong value fails here, where the
|
|
4
|
+
* options arrive, instead of inside a model step, where the AI SDK drops the error.
|
|
5
|
+
* @param {string} entry
|
|
6
|
+
* @param {Record<string, unknown>} callbacks
|
|
7
|
+
*/
|
|
8
|
+
export function assertCallbacks(entry, callbacks) {
|
|
9
|
+
for (const [name, callback] of Object.entries(callbacks)) {
|
|
10
|
+
// readable-check: allow method-probe - input validation: the callbacks an application passes the agent engine
|
|
11
|
+
if (typeof callback !== "function") throw new TypeError(`${entry}: ${name} must be a function, or left out for its default.`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { REASONING, generateOnce } from "./model.js";
|
|
2
|
+
|
|
3
|
+
// Checks on a finished reply. Each can send the reply back once, carrying on from its own steps;
|
|
4
|
+
// `checkTexts(app)` words the note the model gets, signed by the app.
|
|
5
|
+
|
|
6
|
+
export function checkTexts(app) {
|
|
7
|
+
return {
|
|
8
|
+
stoppedShort: `(Check from ${app}: your reply ended by saying what you'd do next, and then stopped, so the person is left waiting. Carry on now with your tools and finish the job, then reply with the result. If you can't, say plainly why.)`,
|
|
9
|
+
markup: `(Check from ${app}: your last message contained raw markup, such as a tool call written out as text or special tokens, so nothing in it ran and the person can't read it. Carry on now: call the tools properly to do what's left, then reply in plain words.)`,
|
|
10
|
+
unsupported: (claims) => `(Check from ${app}: your reply says ${claims.map((claim) => `"${claim}"`).join(", ")}, but no tool in this reply did that, so it hasn't happened. Do it now with the right tool, or answer again without saying it's done.)`,
|
|
11
|
+
resumed: "(This reply was interrupted part-way, when the server running it stopped. Your steps so far are above and what they did stays done. Carry on from where you got to, without redoing them, and finish the job.)"
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// --- Stopping short: a short reply that ends by announcing its next step, with no result.
|
|
16
|
+
const ANNOUNCES_NEXT = /(?:\b(?:let me|let's|I'll|I will|I'm going to|now I'll|next,? I'll|one moment|give me a (?:moment|second|sec)|just a (?:moment|second|sec))\b[^.!?\n]{0,160}[.…:>]*|\bfirst\.)\s*$/iu;
|
|
17
|
+
const REPORTS_RESULT = /\b(?:done|here(?:'s| is| are)|saved|I've|I have|made|ready|finished|let me know|if you(?:'d)? (?:like|want)|say the word)\b/iu;
|
|
18
|
+
/** A plan of a paragraph or two counts too ("…then verify it before switching it in."). */
|
|
19
|
+
export const stoppedShort = (text) => text.length < 600 && ANNOUNCES_NEXT.test(text) && !REPORTS_RESULT.test(text);
|
|
20
|
+
|
|
21
|
+
/** A reply's steps without its closing words, to carry on from. */
|
|
22
|
+
export function withoutClosingWords(transcript) {
|
|
23
|
+
const last = transcript.at(-1);
|
|
24
|
+
const saidOnly = last?.role === "assistant" && (typeof last.content === "string" || last.content.every((part) => part.type === "text"));
|
|
25
|
+
return saidOnly ? transcript.slice(0, -1) : transcript;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// --- Raw model markup: DeepSeek's own tokens (<|begin▁of▁sentence|>), tool calls written out as
|
|
29
|
+
// text (<invoke>, <parameter>, |DSML|) and the blocks that come with them (budget, safety). Its
|
|
30
|
+
// presence means the step was malformed.
|
|
31
|
+
export const LEAKED_MARKUP = /<[||]{1,2}[^<>\n]{0,80}?[||]{1,2}>||DSML||<\/?(?:invoke|parameter|tool_call|function_calls|tools|ds_safety)\b|<budget:/u;
|
|
32
|
+
const CJK = /[㐀-鿿豈-]/gu;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The reply as people may see it: the markup and what it wraps taken out. After one of DeepSeek's
|
|
36
|
+
* tokens, the rest of that line goes too when it's in a script the reply otherwise isn't in.
|
|
37
|
+
*/
|
|
38
|
+
export function withoutMarkup(text) {
|
|
39
|
+
const source = String(text || "");
|
|
40
|
+
if (!LEAKED_MARKUP.test(source)) return source;
|
|
41
|
+
// Judged on the lines without one of its tokens, so the junk itself doesn't count.
|
|
42
|
+
const others = source.split("\n").filter((line) => !/<[||]{1,2}[^<>\n]{0,80}?[||]{1,2}>/u.test(line)).join("\n");
|
|
43
|
+
const foreign = (others.match(CJK) || []).length <= others.length * 0.05;
|
|
44
|
+
return source
|
|
45
|
+
.replace(/<budget:[^>]*>[\s\S]*?<\/budget:[^>]*>/gu, "")
|
|
46
|
+
.replace(/<ds_safety>[\s\S]*?<\/ds_safety>/gu, "")
|
|
47
|
+
.replace(/<(?:[||]*DSML[||]*\s*)?invoke\b[\s\S]*?(?:<\/(?:[||]*DSML[||]*\s*)?invoke>|$)/gu, "")
|
|
48
|
+
.replace(/<(?:[||]*DSML[||]*\s*)?parameter\b[\s\S]*?(?:<\/(?:[||]*DSML[||]*\s*)?parameter>|$)/gu, "")
|
|
49
|
+
.replace(/<[||]{1,2}[^<>\n]{0,80}?[||]{1,2}>([^\n]*)/gu, (match, rest) => (foreign && CJK.test(rest) ? "" : match))
|
|
50
|
+
.replace(/<[||]{1,2}[^<>\n]{0,80}?[||]{1,2}>[ \t]*/gu, "")
|
|
51
|
+
.replace(/<\/?(?:[||]*DSML[||]*\s*)?(?:tool_call|function_calls|tools|invoke|parameter)\b[^>\n]*>?/gu, "")
|
|
52
|
+
.replace(/[||]*DSML[||]*[^\n]*/gu, "")
|
|
53
|
+
.split("\n")
|
|
54
|
+
// What's left of a call in DeepSeek's token form ("runCommand||invoke||…") and lone brackets.
|
|
55
|
+
.filter((line) => !line.includes("|") && !/^\s*[<>/]+\s*$/u.test(line))
|
|
56
|
+
.join("\n")
|
|
57
|
+
.replace(/\n{3,}/gu, "\n\n")
|
|
58
|
+
.trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- Unsupported claims: a reply that says it did something is compared with what its tools did,
|
|
62
|
+
// in one short call. Only claims about this reply's own actions count; plans, offers, questions
|
|
63
|
+
// and things done earlier don't.
|
|
64
|
+
const CLAIMS_ACTION = /\b(?:I(?:'ve| have)?|it's|it is|they're|they are|all|both|now|done|is|are|was|were)\b[^.!?\n]{0,60}\b(?:created|made|saved|sent|added|updated|changed|removed|deleted|moved|renamed|booked|scheduled|set up|attached|uploaded|published|installed|fixed|written|wrote|built|drafted|replied|invited|cancelled|archived|forwarded)\b/iu;
|
|
65
|
+
export const reportsActions = (text) => CLAIMS_ACTION.test(String(text || ""));
|
|
66
|
+
|
|
67
|
+
/** What a reply's tools did, briefly, from its transcript and the app's own records of it. */
|
|
68
|
+
export function toolRecord(transcript = [], events = [], app = "The app") {
|
|
69
|
+
const brief = (value, size = 240) => {
|
|
70
|
+
const text = typeof value === "string" ? value : JSON.stringify(value ?? "");
|
|
71
|
+
return text.length > size ? `${text.slice(0, size)}…` : text;
|
|
72
|
+
};
|
|
73
|
+
const lines = [];
|
|
74
|
+
for (const message of transcript) {
|
|
75
|
+
for (const part of Array.isArray(message.content) ? message.content : []) {
|
|
76
|
+
if (part.type === "tool-call") lines.push(`called ${part.toolName}(${brief(part.input ?? part.args ?? {})})`);
|
|
77
|
+
// readable-check: allow no-dual-shape - data: a tool result's output is { type, value } in model messages and the bare value in transcripts stored before that
|
|
78
|
+
if (part.type === "tool-result") lines.push(` ${part.toolName} answered: ${brief(part.output?.value ?? part.output ?? part.result)}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const event of events) lines.push(`${app} recorded: ${event}`);
|
|
82
|
+
return lines.join("\n") || "(no tool ran in this reply)";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const CLAIMS_CHECKER = [
|
|
86
|
+
"You check an assistant's reply against a record of what its tools did while writing that reply.",
|
|
87
|
+
"List each statement in the reply that says something was done in this reply (made, saved, sent, attached, added, changed, deleted, booked, set up, fixed, run, tested) that the record doesn't support.",
|
|
88
|
+
"A claim is supported if a tool call and its answer in the record show it happened. Ignore plans, offers, questions, advice, facts about the person, things said to have happened earlier in the conversation, and descriptions of what a file or app contains.",
|
|
89
|
+
"Reply with JSON only: {\"unsupported\": [\"short quote of each unsupported claim\"]}, with [] when every claim is supported."
|
|
90
|
+
].join("\n");
|
|
91
|
+
|
|
92
|
+
export async function unsupportedClaims({ model, reply, record }) {
|
|
93
|
+
try {
|
|
94
|
+
const text = await generateOnce({ model, system: CLAIMS_CHECKER, messages: [{ role: "user", content: `Tool record:\n${record}\n\nReply:\n${String(reply).slice(0, 6000)}` }], reasoning: REASONING.off, maxOutputTokens: 400 });
|
|
95
|
+
const parsed = JSON.parse(String(text || "").replace(/^\s*```(?:json)?|```\s*$/gu, "").trim());
|
|
96
|
+
return Array.isArray(parsed.unsupported) ? parsed.unsupported.map(String).filter(Boolean).slice(0, 5) : [];
|
|
97
|
+
} catch {
|
|
98
|
+
// The check is a safety net: when it can't run, the reply goes as it is.
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { SUMMARY_LIMITS } from "./summaries.js";
|
|
2
|
+
import { estimateTokens } from "./text.js";
|
|
3
|
+
|
|
4
|
+
// Context for the model's 1-million-token window. Between turns, a conversation is summarised
|
|
5
|
+
// (summaries.js); what's after the summary is read word for word.
|
|
6
|
+
|
|
7
|
+
// Should summarising fall behind, the oldest messages give way past this size, and the model is
|
|
8
|
+
// told; never silently. It leaves a long turn's own steps room in the window.
|
|
9
|
+
export const HISTORY_MAX_TOKENS = 600000;
|
|
10
|
+
|
|
11
|
+
export function withinBudget(history, maxTokens = HISTORY_MAX_TOKENS) {
|
|
12
|
+
let tokens = estimateTokens(history);
|
|
13
|
+
let start = 0;
|
|
14
|
+
while (start < history.length - 1 && tokens > maxTokens) tokens -= estimateTokens([history[start++]]);
|
|
15
|
+
if (!start) return history;
|
|
16
|
+
// A cut never starts on a tool result whose call was cut.
|
|
17
|
+
while (start < history.length - 1 && history[start].role === "tool") start += 1;
|
|
18
|
+
console.error(`A reply's history was cut by ${start} messages: summarising didn't keep up.`);
|
|
19
|
+
return [{ role: "system", content: "(The earliest part of this conversation isn't shown: it was too long to read and couldn't be summarised just now. If you need something from it, say so rather than guess.)" }, ...history.slice(start)];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function summaryNote(summary) {
|
|
23
|
+
return `Summary of the earlier part of this conversation (those messages are no longer shown; it was written for you, not by the person). If you need a detail that isn't here, say so or look it up; never invent it.\n\n${summary}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Room inside one long turn (as Claude Code's context editing and DeepSeek Harness's result pruning
|
|
27
|
+
// do): before each model call, past `trimAt` estimated tokens, the oldest tool results (never the
|
|
28
|
+
// newest `keepRecent`) give way to a one-line note until the request is under `trimTo`. Calls and
|
|
29
|
+
// the model's words stay. Results once left out stay out for the rest of the turn, so the prompt's
|
|
30
|
+
// start changes rarely and the cache keeps hitting. Stored history is untouched.
|
|
31
|
+
export const TURN_LIMITS = { trimAt: 700000, trimTo: 500000, keepRecent: 20 };
|
|
32
|
+
|
|
33
|
+
const partTokens = (part) => Math.ceil(JSON.stringify(part).length / 4);
|
|
34
|
+
|
|
35
|
+
export function trimOldResults(messages, left = new Set(), limits = TURN_LIMITS) {
|
|
36
|
+
const results = messages.flatMap((message) => (message.role === "tool" && Array.isArray(message.content) ? message.content.filter((part) => part.type === "tool-result") : []));
|
|
37
|
+
const total = messages.reduce((sum, message) => sum + (Array.isArray(message.content) ? message.content.reduce((inner, part) => inner + partTokens(part), 0) : Math.ceil(String(message.content || "").length / 4)), 0);
|
|
38
|
+
if (total > limits.trimAt) {
|
|
39
|
+
let size = total;
|
|
40
|
+
for (const part of results.slice(0, Math.max(0, results.length - limits.keepRecent))) {
|
|
41
|
+
if (size <= limits.trimTo) break;
|
|
42
|
+
if (left.has(part.toolCallId)) continue;
|
|
43
|
+
left.add(part.toolCallId);
|
|
44
|
+
size -= partTokens(part);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!left.size) return messages;
|
|
48
|
+
return messages.map((message) => (message.role === "tool" && Array.isArray(message.content)
|
|
49
|
+
? { ...message, content: message.content.map((part) => (part.type === "tool-result" && left.has(part.toolCallId)
|
|
50
|
+
? { ...part, output: { type: "text", value: `(This older result of ${part.toolName} was left out to save room. Run the tool again if you need it.)` } }
|
|
51
|
+
: part)) }
|
|
52
|
+
: message));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// An earlier turn's tool result, as later turns read it: past `OLD_RESULT_CHARS` it's cut, saying
|
|
56
|
+
// so (a whole page or file isn't needed twice; the model can look again). The same cut every
|
|
57
|
+
// time, so the prompt cache isn't disturbed.
|
|
58
|
+
export const OLD_RESULT_CHARS = 6000;
|
|
59
|
+
export function clipOldResult(message) {
|
|
60
|
+
if (message.role !== "tool" || !Array.isArray(message.content)) return message;
|
|
61
|
+
return {
|
|
62
|
+
...message,
|
|
63
|
+
content: message.content.map((part) => {
|
|
64
|
+
if (part.type !== "tool-result") return part;
|
|
65
|
+
// readable-check: allow no-dual-shape - data: a tool result's output is { type, value } in model messages and the bare value in transcripts stored before that
|
|
66
|
+
const text = typeof part.output?.value === "string" ? part.output.value : JSON.stringify(part.output?.value ?? part.output ?? null);
|
|
67
|
+
return text.length > OLD_RESULT_CHARS ? { ...part, output: { type: "text", value: `${text.slice(0, OLD_RESULT_CHARS)}… [the rest of this result is no longer shown]` } } : part;
|
|
68
|
+
})
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The sizes above are for a window of about a million tokens. For a model with a smaller one
|
|
73
|
+
// (`contextTokens`), each shrinks in proportion: when to summarise, how much is kept word for
|
|
74
|
+
// word, where a long turn's old results give way and the last-resort cut.
|
|
75
|
+
export const WINDOW_TOKENS = 1048576;
|
|
76
|
+
export function windowLimits(contextTokens = WINDOW_TOKENS) {
|
|
77
|
+
const share = Math.min(1, Math.max(0.01, Number(contextTokens) / WINDOW_TOKENS || 1));
|
|
78
|
+
const scaled = (limits, keys) => ({ ...limits, ...Object.fromEntries(keys.map((key) => [key, Math.floor(limits[key] * share)])) });
|
|
79
|
+
return {
|
|
80
|
+
history: Math.floor(HISTORY_MAX_TOKENS * share),
|
|
81
|
+
turn: scaled(TURN_LIMITS, ["trimAt", "trimTo"]),
|
|
82
|
+
summaries: scaled(SUMMARY_LIMITS, ["compactAt", "summariseNowAt", "keepTokens", "minTokens", "readTokens"])
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// @jskit-ai/agent: a working agent loop on the AI SDK. See README.md.
|
|
2
|
+
export { createTool, modelTools } from "./tool.js";
|
|
3
|
+
export { toolSchema } from "./schema.js";
|
|
4
|
+
export { chatModel, generateOnce, REASONING, LOOP_OUTPUT_TOKENS, MODEL_RETRIES } from "./model.js";
|
|
5
|
+
export { runLoop, cleanTranscript, REPLY_DEADLINE_MS } from "./loop.js";
|
|
6
|
+
export { estimateTokens, textPart, READ_LIMIT, sameGist, stripNotes } from "./text.js";
|
|
7
|
+
export { withinBudget, summaryNote, trimOldResults, clipOldResult, windowLimits, TURN_LIMITS, HISTORY_MAX_TOKENS, OLD_RESULT_CHARS, WINDOW_TOKENS } from "./context.js";
|
|
8
|
+
export { checkTexts, stoppedShort, withoutClosingWords, LEAKED_MARKUP, withoutMarkup, reportsActions, toolRecord, unsupportedClaims } from "./checks.js";
|
|
9
|
+
export { compactMessages, writeSummary, transcriptText, cutPoint, SUMMARY_LIMITS } from "./summaries.js";
|
|
10
|
+
export { hearWhileWorking, remindOnRepeat } from "./turn.js";
|
|
11
|
+
export { assertCallbacks } from "./callbacks.js";
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { generateText, stepCountIs, streamText } from "ai";
|
|
2
|
+
import { assertCallbacks } from "./callbacks.js";
|
|
3
|
+
import { LOOP_OUTPUT_TOKENS, MODEL_RETRIES, REASONING, generateOnce } from "./model.js";
|
|
4
|
+
import { TURN_LIMITS, summaryNote, trimOldResults } from "./context.js";
|
|
5
|
+
import { sameGist, stripNotes } from "./text.js";
|
|
6
|
+
import { modelTools } from "./tool.js";
|
|
7
|
+
|
|
8
|
+
// How long one reply may run before it stops and reports (AGENT_REPLY_MINUTES, default 15).
|
|
9
|
+
export const REPLY_DEADLINE_MS = (Number(process.env.AGENT_REPLY_MINUTES) || 15) * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The agent loop: call the model, run the tools it asks for, feed the results back, until it
|
|
13
|
+
* answers without a tool call, runs out of steps or reaches its deadline. Every step reasons; old
|
|
14
|
+
* tool results give way inside a long turn; each finished step can be saved. A reply always ends
|
|
15
|
+
* with a message: one that stops early ends with the model's own account of where it got to.
|
|
16
|
+
*
|
|
17
|
+
* `history` is the conversation as model messages. The summary, the state note and any other
|
|
18
|
+
* system messages in it go into the system prompt, after the instructions, in that order.
|
|
19
|
+
* Returns { text, transcript, unfinished?, account? }; `unfinished` (out of steps or time) means the
|
|
20
|
+
* job can carry on from here, and `account` is the account the reply ends with, when it ends with
|
|
21
|
+
* one, so a check that sends a reply back can leave the engine's own words alone. With `onText(text)` the reply streams: each step's words so far, as they
|
|
22
|
+
* come. `onStep(step)` hears each finished step. Aborting `signal` (the person pressed Stop, or a
|
|
23
|
+
* tool's outcome is unknown) ends the run at once with the signal's reason, without an account.
|
|
24
|
+
* When `throwIf(error, steps)` says so, a failed call ends the run with its error instead of an
|
|
25
|
+
* account (an app that shows errors itself, when nothing was done yet). Each callback option is a
|
|
26
|
+
* function, or left out for its named default; anything else is refused with a TypeError naming it.
|
|
27
|
+
*/
|
|
28
|
+
export async function runLoop({
|
|
29
|
+
model, name, instructions, history, tools = {}, summary = "", state = "", maxSteps,
|
|
30
|
+
onSteps = saveNoSteps, onStep = ignoreStep, onText = null, signal = null, carriesOn = false, quietIfEmpty = false, clean = stripNotes, throwIf = neverThrow,
|
|
31
|
+
reasoning = REASONING.on, maxOutputTokens = LOOP_OUTPUT_TOKENS, turnLimits = TURN_LIMITS, deadlineMs = REPLY_DEADLINE_MS
|
|
32
|
+
}) {
|
|
33
|
+
assertCallbacks("runLoop", { onSteps, onStep, clean, throwIf });
|
|
34
|
+
const messages = [...history];
|
|
35
|
+
// A transcript cut mid-way must not start with a tool result.
|
|
36
|
+
while (messages.length && messages[0].role === "tool") messages.shift();
|
|
37
|
+
if (summary) messages.unshift({ role: "system", content: summaryNote(summary) });
|
|
38
|
+
if (state) messages.splice(Math.max(0, messages.length - 1), 0, { role: "system", content: state });
|
|
39
|
+
const system = [instructions, ...messages.filter((message) => message.role === "system").map((message) => message.content)];
|
|
40
|
+
const conversation = messages.filter((message) => message.role !== "system");
|
|
41
|
+
|
|
42
|
+
// Each step reports only its own messages; the run's transcript is all of them, in order.
|
|
43
|
+
const done = [];
|
|
44
|
+
const generated = [];
|
|
45
|
+
const left = new Set();
|
|
46
|
+
const deadline = new AbortController();
|
|
47
|
+
const timer = setTimeout(() => deadline.abort(), deadlineMs);
|
|
48
|
+
let result = null;
|
|
49
|
+
let stopped = "";
|
|
50
|
+
try {
|
|
51
|
+
const call = {
|
|
52
|
+
model,
|
|
53
|
+
instructions: systemPrompt(model, system),
|
|
54
|
+
messages: conversation,
|
|
55
|
+
tools: modelTools(tools),
|
|
56
|
+
stopWhen: stepCountIs(maxSteps),
|
|
57
|
+
providerOptions: reasoning,
|
|
58
|
+
maxOutputTokens,
|
|
59
|
+
maxRetries: MODEL_RETRIES,
|
|
60
|
+
abortSignal: signal ? AbortSignal.any([deadline.signal, signal]) : deadline.signal,
|
|
61
|
+
prepareStep: ({ messages: current }) => {
|
|
62
|
+
const trimmed = trimOldResults(current, left, turnLimits);
|
|
63
|
+
return trimmed === current ? {} : { messages: trimmed };
|
|
64
|
+
},
|
|
65
|
+
onStepFinish: async (step) => {
|
|
66
|
+
done.push(step);
|
|
67
|
+
generated.push(...(step.response?.messages || []));
|
|
68
|
+
await onStep(step);
|
|
69
|
+
await onSteps(cleanTranscript(generated)).catch((error) => console.error(`Saving ${name}'s steps: ${error?.message || error}`));
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
result = onText ? await streamed(call, onText) : await generateText(call);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (signal?.aborted) throw signal.reason ?? error;
|
|
75
|
+
if (throwIf(error, done)) throw error;
|
|
76
|
+
stopped = deadline.signal.aborted ? "it was taking too long" : "something went wrong on my side";
|
|
77
|
+
console.error(`${name}'s reply stopped: ${error?.message || error}`);
|
|
78
|
+
} finally {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
}
|
|
81
|
+
const transcript = result ? cleanTranscript(generated) : [];
|
|
82
|
+
let text = result ? clean(replyText(transcript) || result.text) : "";
|
|
83
|
+
const outOfSteps = result && (result.finishReason === "tool-calls" || done.length >= maxSteps);
|
|
84
|
+
if (outOfSteps) stopped = "I reached the most steps I take in one go";
|
|
85
|
+
if (result && deadline.signal.aborted) stopped = "it was taking too long";
|
|
86
|
+
const resumable = Boolean(outOfSteps || deadline.signal.aborted);
|
|
87
|
+
// One that may only chime in (a group follow-up) may say nothing at all.
|
|
88
|
+
let account = "";
|
|
89
|
+
if (stopped || (!text && !quietIfEmpty)) {
|
|
90
|
+
account = await sumUp({ model, name, conversation, steps: done, reason: stopped || "I didn't write anything at the end", carriesOn: carriesOn && resumable, clean });
|
|
91
|
+
text = [text, account].filter(Boolean).join("\n\n");
|
|
92
|
+
transcript.push({ role: "assistant", content: account });
|
|
93
|
+
}
|
|
94
|
+
return { text, transcript, ...(resumable ? { unfinished: stopped } : {}), ...(account ? { account } : {}) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A run whose steps nobody saves. */
|
|
98
|
+
async function saveNoSteps() {}
|
|
99
|
+
|
|
100
|
+
/** A run whose finished steps nobody hears. */
|
|
101
|
+
function ignoreStep() {}
|
|
102
|
+
|
|
103
|
+
/** A run whose failed call always ends with an account, never with its error. */
|
|
104
|
+
function neverThrow() {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A streamed run: each step's words go to `onText` as they come; the result is read at the end. */
|
|
109
|
+
async function streamed(call, onText) {
|
|
110
|
+
const stream = streamText({ ...call, onError: () => {} });
|
|
111
|
+
let text = "";
|
|
112
|
+
for await (const part of stream.fullStream) {
|
|
113
|
+
if (part.type === "error") throw part.error;
|
|
114
|
+
if (part.type === "start-step") text = "";
|
|
115
|
+
if (part.type === "text-delta" && part.text) await onText((text += part.text));
|
|
116
|
+
}
|
|
117
|
+
return { finishReason: await stream.finishReason, text: await stream.text };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The system prompt as each format takes it: one message in the OpenAI style, parts joined by a
|
|
122
|
+
* blank line; separate blocks in the Anthropic format.
|
|
123
|
+
*/
|
|
124
|
+
function systemPrompt(model, parts) {
|
|
125
|
+
const present = parts.filter(Boolean);
|
|
126
|
+
return String(model.provider || "").startsWith("anthropic") ? present.map((content) => ({ role: "system", content })) : present.join("\n\n");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A reply's stored transcript keeps its tool calls and results; reasoning and provider
|
|
131
|
+
* bookkeeping are dropped.
|
|
132
|
+
*/
|
|
133
|
+
export function cleanTranscript(messages = []) {
|
|
134
|
+
return messages.map((message) => ({
|
|
135
|
+
role: message.role,
|
|
136
|
+
content: Array.isArray(message.content)
|
|
137
|
+
? message.content.filter((part) => part.type !== "reasoning").map(({ providerOptions, ...part }) => part)
|
|
138
|
+
: message.content
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The words of a reply: each step's text a paragraph, leaving out a step that repeats an earlier
|
|
144
|
+
* one's opening, or says again in other words what a later one says (the later one is the answer).
|
|
145
|
+
*/
|
|
146
|
+
function replyText(transcript) {
|
|
147
|
+
const opening = (text) => text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim().slice(0, 60);
|
|
148
|
+
return transcript
|
|
149
|
+
.filter((message) => message.role === "assistant")
|
|
150
|
+
.flatMap((message) => (Array.isArray(message.content) ? message.content.filter((part) => part.type === "text").map((part) => part.text) : [message.content]))
|
|
151
|
+
.map((text) => String(text || "").trim())
|
|
152
|
+
.filter(Boolean)
|
|
153
|
+
.filter((text, index, all) => all.findIndex((other) => opening(other) === opening(text)) === index)
|
|
154
|
+
.filter((text, index, all) => !all.slice(index + 1).some((later) => sameGist(text, later)))
|
|
155
|
+
.join("\n\n");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** What the steps did, briefly, for the account of a reply that stopped early. */
|
|
159
|
+
function stepLog(steps) {
|
|
160
|
+
const brief = (value) => {
|
|
161
|
+
const text = typeof value === "string" ? value : JSON.stringify(value ?? "");
|
|
162
|
+
return text.length > 300 ? `${text.slice(0, 300)}…` : text;
|
|
163
|
+
};
|
|
164
|
+
return steps.slice(-20).flatMap((step) => [
|
|
165
|
+
...(step.toolCalls || []).map((call) => `- called ${call.toolName}(${brief(call.input ?? call.args)})`),
|
|
166
|
+
...(step.toolResults || []).map((answer) => ` → ${brief(answer.output ?? answer.result)}`),
|
|
167
|
+
...(String(step.text || "").trim() ? [`- said: ${brief(step.text)}`] : [])
|
|
168
|
+
]).join("\n") || "(no steps finished)";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function sumUp({ model, name, conversation, steps, reason, carriesOn, clean }) {
|
|
172
|
+
const asked = [...conversation].reverse().find((message) => message.role === "user");
|
|
173
|
+
try {
|
|
174
|
+
const text = clean(await generateOnce({
|
|
175
|
+
model,
|
|
176
|
+
system: [
|
|
177
|
+
`You are ${name}, a personal assistant bot. You were working on the person's request but stopped early because ${reason}.`,
|
|
178
|
+
carriesOn
|
|
179
|
+
? "Write one short chat message: what you did, where things stand now, and what's left, which you're carrying on with by yourself in the background now (they don't need to do anything)."
|
|
180
|
+
: "Write one short chat message: what you did, where things stand now, what's left, and that they can ask you to carry on.",
|
|
181
|
+
"Only mention what the log shows actually happened. Never claim a step the log doesn't show, and never ask for passwords."
|
|
182
|
+
].join("\n"),
|
|
183
|
+
messages: [{ role: "user", content: `The request: ${typeof asked?.content === "string" ? asked.content.slice(0, 1500) : "(see the log)"}\n\nWhat happened:\n${stepLog(steps)}` }]
|
|
184
|
+
}));
|
|
185
|
+
if (text) return text;
|
|
186
|
+
} catch {
|
|
187
|
+
// The plain account below still tells the person.
|
|
188
|
+
}
|
|
189
|
+
return carriesOn
|
|
190
|
+
? `I paused because ${reason}, and I'm carrying on from where things are by myself now.`
|
|
191
|
+
: `I stopped before finishing, because ${reason}. Ask me to carry on and I'll pick it up from where things are.`;
|
|
192
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
2
|
+
import { createDeepSeek } from "@ai-sdk/deepseek";
|
|
3
|
+
import { generateText } from "ai";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A provider's chat model. With an Anthropic-style address (DeepSeek's own API) it's called in
|
|
7
|
+
* that format, as Claude Code calls it, so signed reasoning goes back on every step; otherwise in
|
|
8
|
+
* the OpenAI style (Together AI, DeepSeek), where reasoning goes back as reasoning_content.
|
|
9
|
+
*/
|
|
10
|
+
export function chatModel({ apiKey, model, baseURL, anthropicURL = "", fetch: fetchImpl }) {
|
|
11
|
+
const client = anthropicURL
|
|
12
|
+
? createAnthropic({ apiKey, baseURL: anthropicURL, fetch: fetchImpl })
|
|
13
|
+
: createDeepSeek({ apiKey, baseURL, fetch: fetchImpl });
|
|
14
|
+
return client(model);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Reasoning, written for both formats (each reads only its own options). On: adaptive thinking at
|
|
18
|
+
// high effort, so the model spends a lot on a hard step and almost nothing on "hi"; the OpenAI-style
|
|
19
|
+
// client thinks when no setting is sent. Off: for quick one-shot calls.
|
|
20
|
+
export const REASONING = {
|
|
21
|
+
on: { anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" } },
|
|
22
|
+
off: { deepseek: { thinking: { type: "disabled" } }, anthropic: { thinking: { type: "disabled" } } }
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// A loop step may write a long answer: the Anthropic format needs a cap, and its default is small.
|
|
26
|
+
export const LOOP_OUTPUT_TOKENS = 32000;
|
|
27
|
+
|
|
28
|
+
// Failed model calls are tried again with growing waits (2, 4, 8… seconds), about two minutes in
|
|
29
|
+
// all, before a reply gives up and says so.
|
|
30
|
+
export const MODEL_RETRIES = 6;
|
|
31
|
+
|
|
32
|
+
/** One call without tools, such as a summary or a short label; returns its text. */
|
|
33
|
+
export async function generateOnce({ model, system, messages, reasoning = REASONING.off, maxOutputTokens }) {
|
|
34
|
+
const result = await generateText({
|
|
35
|
+
model,
|
|
36
|
+
...(system ? { system } : {}),
|
|
37
|
+
messages,
|
|
38
|
+
providerOptions: reasoning,
|
|
39
|
+
maxRetries: MODEL_RETRIES,
|
|
40
|
+
...(maxOutputTokens ? { maxOutputTokens } : {})
|
|
41
|
+
});
|
|
42
|
+
return result.text;
|
|
43
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// A tool's input schema as the model is shown it. DeepSeek models get the limits of strings,
|
|
4
|
+
// numbers and arrays written into the field's description ("constraints: maximum length 200")
|
|
5
|
+
// instead of as JSON Schema keywords. This is the form every evaluation was measured with, so it
|
|
6
|
+
// stays exactly so. Input is still checked against the original schema.
|
|
7
|
+
const STRING_LIMITS = {
|
|
8
|
+
minLength: (value) => `minimum length ${value}`,
|
|
9
|
+
maxLength: (value) => `maximum length ${value}`,
|
|
10
|
+
pattern: (value) => `input must match this regex ${value}`
|
|
11
|
+
};
|
|
12
|
+
const FORMATS = new Set(["email", "uri", "url", "uuid", "cuid", "emoji"]);
|
|
13
|
+
const NUMBER_LIMITS = {
|
|
14
|
+
minimum: (value) => `greater than or equal to ${value}`,
|
|
15
|
+
exclusiveMinimum: (value) => `greater than ${value}`,
|
|
16
|
+
maximum: (value) => `lower than or equal to ${value}`,
|
|
17
|
+
exclusiveMaximum: (value) => `lower than ${value}`,
|
|
18
|
+
multipleOf: (value) => `multiple of ${value}`
|
|
19
|
+
};
|
|
20
|
+
const REWRAPPED = new Set(["object", "array", "string", "number", "integer"]);
|
|
21
|
+
// A whole number's implicit range isn't a limit anyone set.
|
|
22
|
+
const SAFE = Number.MAX_SAFE_INTEGER;
|
|
23
|
+
const ARRAY_LIMITS = {
|
|
24
|
+
minItems: (value) => `minimum length ${value}`,
|
|
25
|
+
maxItems: (value) => `maximum length ${value}`
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function describe(node, constraints) {
|
|
29
|
+
if (!constraints.length) return node;
|
|
30
|
+
const { description, ...rest } = node;
|
|
31
|
+
return { ...rest, description: `${description ? `${description}\n` : ""}constraints: ${constraints.join(", ")}` };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function rewrite(node) {
|
|
35
|
+
if (Array.isArray(node)) return node.map(rewrite);
|
|
36
|
+
if (!node || typeof node !== "object") return node;
|
|
37
|
+
let out = Object.fromEntries(Object.entries(node).map(([key, value]) => [key, typeof value === "object" ? rewrite(value) : value]));
|
|
38
|
+
if (out.type === "string") {
|
|
39
|
+
const constraints = [];
|
|
40
|
+
// A format (email, url) stands for its own pattern.
|
|
41
|
+
const formatted = FORMATS.has(out.format);
|
|
42
|
+
for (const [key, value] of Object.entries(out)) {
|
|
43
|
+
if (STRING_LIMITS[key]) {
|
|
44
|
+
if (!(key === "pattern" && formatted)) constraints.push(STRING_LIMITS[key](value));
|
|
45
|
+
delete out[key];
|
|
46
|
+
} else if (key === "format" && formatted) {
|
|
47
|
+
constraints.push(`a valid ${value === "uri" ? "url" : value}`);
|
|
48
|
+
delete out[key];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
out = describe(out, constraints);
|
|
52
|
+
} else if (out.type === "number" || out.type === "integer") {
|
|
53
|
+
const constraints = [];
|
|
54
|
+
for (const [key, value] of Object.entries(out)) {
|
|
55
|
+
if (!NUMBER_LIMITS[key]) continue;
|
|
56
|
+
if (out.type !== "integer" || Math.abs(value) !== SAFE) constraints.push(NUMBER_LIMITS[key](value));
|
|
57
|
+
delete out[key];
|
|
58
|
+
}
|
|
59
|
+
out = describe(out, constraints);
|
|
60
|
+
} else if (out.type === "object") {
|
|
61
|
+
// An object of named fields is closed; one with no fields (a record) states that none is required.
|
|
62
|
+
if (out.properties && !("additionalProperties" in out)) out.additionalProperties = false;
|
|
63
|
+
// An optional text, number, list, object or either-or field with a description of its own is
|
|
64
|
+
// written description first.
|
|
65
|
+
const required = new Set(out.required || []);
|
|
66
|
+
for (const [key, field] of Object.entries(out.properties || {})) {
|
|
67
|
+
if (!required.has(key) && node.properties[key].description !== undefined && (REWRAPPED.has(field.type) || field.anyOf)) {
|
|
68
|
+
const { description, ...rest } = field;
|
|
69
|
+
out.properties[key] = { description, ...rest };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (!Object.keys(out.properties || {}).length) out.required ??= [];
|
|
73
|
+
} else if (out.type === "array") {
|
|
74
|
+
const constraints = [];
|
|
75
|
+
for (const [key, value] of Object.entries(out)) {
|
|
76
|
+
if (ARRAY_LIMITS[key]) {
|
|
77
|
+
constraints.push(ARRAY_LIMITS[key](value));
|
|
78
|
+
delete out[key];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
out = describe(out, constraints);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function toolSchema(schema) {
|
|
87
|
+
return rewrite(z.toJSONSchema(schema, { target: "draft-7", io: "input", unrepresentable: "any" }));
|
|
88
|
+
}
|