@k2b/nessi 0.10.0-rc.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/LICENSE +21 -0
- package/README.md +251 -0
- package/aggregates.d.ts +7 -0
- package/aggregates.js +115 -0
- package/ai/complete-from-stream.d.ts +2 -0
- package/ai/complete-from-stream.js +36 -0
- package/ai/index.d.ts +10 -0
- package/ai/index.js +9 -0
- package/ai/providers/anthropic.d.ts +13 -0
- package/ai/providers/anthropic.js +266 -0
- package/ai/providers/gemini.d.ts +12 -0
- package/ai/providers/gemini.js +192 -0
- package/ai/providers/mistral.d.ts +12 -0
- package/ai/providers/mistral.js +287 -0
- package/ai/providers/ollama.d.ts +10 -0
- package/ai/providers/ollama.js +241 -0
- package/ai/providers/openai-compatible.d.ts +2 -0
- package/ai/providers/openai-compatible.js +349 -0
- package/ai/providers/openai.d.ts +12 -0
- package/ai/providers/openai.js +22 -0
- package/ai/providers/openrouter.d.ts +13 -0
- package/ai/providers/openrouter.js +28 -0
- package/ai/providers/vllm.d.ts +11 -0
- package/ai/providers/vllm.js +22 -0
- package/ai/shared/errors.d.ts +15 -0
- package/ai/shared/errors.js +56 -0
- package/ai/shared/json.d.ts +3 -0
- package/ai/shared/json.js +15 -0
- package/ai/shared/messages.d.ts +15 -0
- package/ai/shared/messages.js +58 -0
- package/ai/shared/ndjson.d.ts +4 -0
- package/ai/shared/ndjson.js +60 -0
- package/ai/shared/sse.d.ts +15 -0
- package/ai/shared/sse.js +79 -0
- package/ai/shared/stream-helpers.d.ts +13 -0
- package/ai/shared/stream-helpers.js +105 -0
- package/ai/shared/tool-call-ids.d.ts +5 -0
- package/ai/shared/tool-call-ids.js +38 -0
- package/ai/shared/tool-stream-normalizer.d.ts +6 -0
- package/ai/shared/tool-stream-normalizer.js +271 -0
- package/ai/shared/tools.d.ts +29 -0
- package/ai/shared/tools.js +25 -0
- package/ai/shared/usage.d.ts +3 -0
- package/ai/shared/usage.js +5 -0
- package/ai/types.d.ts +252 -0
- package/ai/types.js +0 -0
- package/compact.d.ts +5 -0
- package/compact.js +108 -0
- package/index.d.ts +11 -0
- package/index.js +12 -0
- package/nessi.d.ts +2 -0
- package/nessi.js +1250 -0
- package/package.json +80 -0
- package/providers/ollama.d.ts +2 -0
- package/providers/ollama.js +1 -0
- package/providers/openai.d.ts +2 -0
- package/providers/openai.js +1 -0
- package/providers/openrouter.d.ts +2 -0
- package/providers/openrouter.js +1 -0
- package/stores.d.ts +11 -0
- package/stores.js +42 -0
- package/structured.d.ts +9 -0
- package/structured.js +413 -0
- package/tools.d.ts +25 -0
- package/tools.js +36 -0
- package/types.d.ts +290 -0
- package/types.js +3 -0
- package/utils.d.ts +15 -0
- package/utils.js +47 -0
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@k2b/nessi",
|
|
3
|
+
"version": "0.10.0-rc.0",
|
|
4
|
+
"description": "Minimal agent loop and provider adapters for nessi.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"module": "index.js",
|
|
7
|
+
"types": "index.d.ts",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"sideEffects": false,
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": "./index.js",
|
|
13
|
+
"types": "./index.d.ts"
|
|
14
|
+
},
|
|
15
|
+
"./ai": {
|
|
16
|
+
"import": "./ai/index.js",
|
|
17
|
+
"types": "./ai/index.d.ts"
|
|
18
|
+
},
|
|
19
|
+
"./ai/providers/openai-compatible": {
|
|
20
|
+
"import": "./ai/providers/openai-compatible.js",
|
|
21
|
+
"types": "./ai/providers/openai-compatible.d.ts"
|
|
22
|
+
},
|
|
23
|
+
"./ai/providers/openai": {
|
|
24
|
+
"import": "./ai/providers/openai.js",
|
|
25
|
+
"types": "./ai/providers/openai.d.ts"
|
|
26
|
+
},
|
|
27
|
+
"./ai/providers/openrouter": {
|
|
28
|
+
"import": "./ai/providers/openrouter.js",
|
|
29
|
+
"types": "./ai/providers/openrouter.d.ts"
|
|
30
|
+
},
|
|
31
|
+
"./ai/providers/vllm": {
|
|
32
|
+
"import": "./ai/providers/vllm.js",
|
|
33
|
+
"types": "./ai/providers/vllm.d.ts"
|
|
34
|
+
},
|
|
35
|
+
"./ai/providers/ollama": {
|
|
36
|
+
"import": "./ai/providers/ollama.js",
|
|
37
|
+
"types": "./ai/providers/ollama.d.ts"
|
|
38
|
+
},
|
|
39
|
+
"./ai/providers/anthropic": {
|
|
40
|
+
"import": "./ai/providers/anthropic.js",
|
|
41
|
+
"types": "./ai/providers/anthropic.d.ts"
|
|
42
|
+
},
|
|
43
|
+
"./ai/providers/mistral": {
|
|
44
|
+
"import": "./ai/providers/mistral.js",
|
|
45
|
+
"types": "./ai/providers/mistral.d.ts"
|
|
46
|
+
},
|
|
47
|
+
"./ai/providers/gemini": {
|
|
48
|
+
"import": "./ai/providers/gemini.js",
|
|
49
|
+
"types": "./ai/providers/gemini.d.ts"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"files": [
|
|
53
|
+
"**/*"
|
|
54
|
+
],
|
|
55
|
+
"keywords": [
|
|
56
|
+
"ai",
|
|
57
|
+
"llm",
|
|
58
|
+
"agent",
|
|
59
|
+
"tools",
|
|
60
|
+
"streaming",
|
|
61
|
+
"openai",
|
|
62
|
+
"openrouter",
|
|
63
|
+
"ollama",
|
|
64
|
+
"anthropic",
|
|
65
|
+
"gemini",
|
|
66
|
+
"mistral"
|
|
67
|
+
],
|
|
68
|
+
"author": "Valentin Kolb",
|
|
69
|
+
"license": "MIT",
|
|
70
|
+
"repository": {
|
|
71
|
+
"type": "git",
|
|
72
|
+
"url": "git+https://github.com/k2b-dev/nessi.git"
|
|
73
|
+
},
|
|
74
|
+
"publishConfig": {
|
|
75
|
+
"access": "public"
|
|
76
|
+
},
|
|
77
|
+
"dependencies": {
|
|
78
|
+
"zod": "^4.3.0"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ollama } from "../ai/providers/ollama.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { openai } from "../ai/providers/openai.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { openrouter } from "../ai/providers/openrouter.js";
|
package/stores.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SessionStore } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* In-memory SessionStore. For tests, prototyping, single-use sessions.
|
|
4
|
+
*
|
|
5
|
+
* Messages are never deleted. Summaries are inserted via append() with
|
|
6
|
+
* `{ kind: "summary" }`. load() returns entries from the last summary onward.
|
|
7
|
+
*
|
|
8
|
+
* Tiebreaker: at the same seq, "summary" sorts after "message" so that
|
|
9
|
+
* the summary replaces (not precedes) messages at that position.
|
|
10
|
+
*/
|
|
11
|
+
export declare const memoryStore: () => SessionStore;
|
package/stores.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// nessi – Session Stores
|
|
3
|
+
// ============================================================================
|
|
4
|
+
/**
|
|
5
|
+
* In-memory SessionStore. For tests, prototyping, single-use sessions.
|
|
6
|
+
*
|
|
7
|
+
* Messages are never deleted. Summaries are inserted via append() with
|
|
8
|
+
* `{ kind: "summary" }`. load() returns entries from the last summary onward.
|
|
9
|
+
*
|
|
10
|
+
* Tiebreaker: at the same seq, "summary" sorts after "message" so that
|
|
11
|
+
* the summary replaces (not precedes) messages at that position.
|
|
12
|
+
*/
|
|
13
|
+
export const memoryStore = () => {
|
|
14
|
+
const entries = [];
|
|
15
|
+
let nextSeq = 1;
|
|
16
|
+
const sortEntries = () => {
|
|
17
|
+
entries.sort((a, b) => {
|
|
18
|
+
if (a.seq !== b.seq)
|
|
19
|
+
return a.seq - b.seq;
|
|
20
|
+
// summary after message at same seq
|
|
21
|
+
if (a.kind === "summary" && b.kind === "message")
|
|
22
|
+
return 1;
|
|
23
|
+
if (a.kind === "message" && b.kind === "summary")
|
|
24
|
+
return -1;
|
|
25
|
+
return 0;
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
async load() {
|
|
30
|
+
const lastSummaryIdx = entries.findLastIndex(e => e.kind === "summary");
|
|
31
|
+
return lastSummaryIdx >= 0 ? entries.slice(lastSummaryIdx) : [...entries];
|
|
32
|
+
},
|
|
33
|
+
async append(message, opts) {
|
|
34
|
+
const seq = opts?.seq ?? nextSeq++;
|
|
35
|
+
const kind = opts?.kind ?? "message";
|
|
36
|
+
entries.push({ seq, kind, message });
|
|
37
|
+
sortEntries();
|
|
38
|
+
// Ensure nextSeq stays ahead
|
|
39
|
+
nextSeq = Math.max(nextSeq, seq + 1);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
};
|
package/structured.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { StructuredOptions, StructuredResult } from "./types.js";
|
|
3
|
+
export type StructuredOutputErrorCode = "invalid_output" | "unsupported_tool" | "loop_failed" | "max_turns" | "aborted";
|
|
4
|
+
export declare class StructuredOutputError extends Error {
|
|
5
|
+
readonly code: StructuredOutputErrorCode;
|
|
6
|
+
readonly details?: unknown;
|
|
7
|
+
constructor(message: string, code: StructuredOutputErrorCode, details?: unknown);
|
|
8
|
+
}
|
|
9
|
+
export declare const structured: <TOutput extends z.ZodType>(options: StructuredOptions<TOutput>) => Promise<StructuredResult<z.infer<TOutput>>>;
|
package/structured.js
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// nessi.structured() - schema-valid task helper
|
|
3
|
+
// ============================================================================
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { aggregateFromTurns, buildLoopTiming } from "./aggregates.js";
|
|
6
|
+
import { extractAssistantText } from "./ai/shared/messages.js";
|
|
7
|
+
import { nessi } from "./nessi.js";
|
|
8
|
+
import { memoryStore } from "./stores.js";
|
|
9
|
+
import { defineTool } from "./tools.js";
|
|
10
|
+
import { createLoopId, toErrorMessage } from "./utils.js";
|
|
11
|
+
const DEFAULT_SYSTEM_PROMPT = "You produce schema-valid structured data for the caller.";
|
|
12
|
+
const SUBMIT_RESULT_TOOL_NAME = "submit_result";
|
|
13
|
+
export class StructuredOutputError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
details;
|
|
16
|
+
constructor(message, code, details) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "StructuredOutputError";
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.details = details;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const normalizeContentParts = (parts) => parts.map((part) => (typeof part === "string" ? { type: "text", text: part } : part));
|
|
24
|
+
const normalizeStructuredInput = (input) => {
|
|
25
|
+
if (typeof input === "string")
|
|
26
|
+
return { role: "user", content: [{ type: "text", text: input }] };
|
|
27
|
+
if (Array.isArray(input))
|
|
28
|
+
return { role: "user", content: normalizeContentParts(input) };
|
|
29
|
+
return { role: "user", content: normalizeContentParts(input.content) };
|
|
30
|
+
};
|
|
31
|
+
const schemaFor = (schema) => stripJsonSchemaMetadata(z.toJSONSchema(schema, { target: "draft-07" }));
|
|
32
|
+
const stripJsonSchemaMetadata = (value) => {
|
|
33
|
+
if (Array.isArray(value))
|
|
34
|
+
return value.map(stripJsonSchemaMetadata);
|
|
35
|
+
if (!isRecord(value))
|
|
36
|
+
return value;
|
|
37
|
+
const out = {};
|
|
38
|
+
for (const [key, child] of Object.entries(value)) {
|
|
39
|
+
if (key === "$schema")
|
|
40
|
+
continue;
|
|
41
|
+
out[key] = stripJsonSchemaMetadata(child);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
};
|
|
45
|
+
const createResponseFormat = (schema, name) => ({
|
|
46
|
+
type: "json_schema",
|
|
47
|
+
name: name ?? "structured_output",
|
|
48
|
+
schema,
|
|
49
|
+
});
|
|
50
|
+
const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
51
|
+
const hasUnsupportedComposition = (schema) => "anyOf" in schema || "oneOf" in schema || "allOf" in schema || "not" in schema;
|
|
52
|
+
const isStrictNativeSchemaCompatible = (schema) => {
|
|
53
|
+
if (!isRecord(schema))
|
|
54
|
+
return false;
|
|
55
|
+
if (hasUnsupportedComposition(schema))
|
|
56
|
+
return false;
|
|
57
|
+
const type = schema.type;
|
|
58
|
+
if (type === "object") {
|
|
59
|
+
const properties = schema.properties;
|
|
60
|
+
const required = schema.required;
|
|
61
|
+
if (!isRecord(properties))
|
|
62
|
+
return true;
|
|
63
|
+
if (!Array.isArray(required))
|
|
64
|
+
return false;
|
|
65
|
+
for (const key of Object.keys(properties)) {
|
|
66
|
+
if (!required.includes(key))
|
|
67
|
+
return false;
|
|
68
|
+
if (!isStrictNativeSchemaCompatible(properties[key]))
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (type === "array") {
|
|
74
|
+
return schema.items === undefined || isStrictNativeSchemaCompatible(schema.items);
|
|
75
|
+
}
|
|
76
|
+
if (Array.isArray(type)) {
|
|
77
|
+
return type.every((entry) => typeof entry === "string" && ["string", "number", "integer", "boolean", "null"].includes(entry));
|
|
78
|
+
}
|
|
79
|
+
if (typeof type === "string") {
|
|
80
|
+
return ["string", "number", "integer", "boolean", "null"].includes(type);
|
|
81
|
+
}
|
|
82
|
+
return Array.isArray(schema.enum) || Object.prototype.hasOwnProperty.call(schema, "const");
|
|
83
|
+
};
|
|
84
|
+
const textFromMessage = (message) => extractAssistantText(message);
|
|
85
|
+
const tryParseJson = (text) => {
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(text);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const findBalancedJson = (text) => {
|
|
94
|
+
let start = -1;
|
|
95
|
+
for (let index = 0; index < text.length; index++) {
|
|
96
|
+
const char = text[index];
|
|
97
|
+
if (char === "{" || char === "[") {
|
|
98
|
+
start = index;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (start < 0)
|
|
103
|
+
return undefined;
|
|
104
|
+
const stack = [];
|
|
105
|
+
let inString = false;
|
|
106
|
+
let escaped = false;
|
|
107
|
+
for (let index = start; index < text.length; index++) {
|
|
108
|
+
const char = text[index];
|
|
109
|
+
if (inString) {
|
|
110
|
+
if (escaped) {
|
|
111
|
+
escaped = false;
|
|
112
|
+
}
|
|
113
|
+
else if (char === "\\") {
|
|
114
|
+
escaped = true;
|
|
115
|
+
}
|
|
116
|
+
else if (char === "\"") {
|
|
117
|
+
inString = false;
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (char === "\"") {
|
|
122
|
+
inString = true;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (char === "{" || char === "[") {
|
|
126
|
+
stack.push(char === "{" ? "}" : "]");
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (char !== "}" && char !== "]")
|
|
130
|
+
continue;
|
|
131
|
+
const expected = stack.pop();
|
|
132
|
+
if (expected !== char)
|
|
133
|
+
return undefined;
|
|
134
|
+
if (stack.length === 0)
|
|
135
|
+
return text.slice(start, index + 1);
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
};
|
|
139
|
+
const extractJsonText = (raw) => {
|
|
140
|
+
const trimmed = raw.trim();
|
|
141
|
+
if (!trimmed)
|
|
142
|
+
return undefined;
|
|
143
|
+
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)?.[1]?.trim();
|
|
144
|
+
if (fenced && tryParseJson(fenced) !== undefined)
|
|
145
|
+
return fenced;
|
|
146
|
+
if (tryParseJson(trimmed) !== undefined)
|
|
147
|
+
return trimmed;
|
|
148
|
+
return findBalancedJson(trimmed);
|
|
149
|
+
};
|
|
150
|
+
const parseStructuredOutput = (schema, raw) => {
|
|
151
|
+
const jsonText = extractJsonText(raw);
|
|
152
|
+
if (!jsonText) {
|
|
153
|
+
return { ok: false, message: "No JSON value was found in the assistant response." };
|
|
154
|
+
}
|
|
155
|
+
let json;
|
|
156
|
+
try {
|
|
157
|
+
json = JSON.parse(jsonText);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
message: `Assistant response contained invalid JSON: ${toErrorMessage(error)}`,
|
|
163
|
+
jsonText,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const parsed = schema.safeParse(json);
|
|
167
|
+
if (!parsed.success) {
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
message: "Assistant response did not match the requested schema.",
|
|
171
|
+
details: parsed.error.issues,
|
|
172
|
+
jsonText,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return { ok: true, value: parsed.data, jsonText };
|
|
176
|
+
};
|
|
177
|
+
const schemaInstruction = (schema, outputName) => [
|
|
178
|
+
"Return exactly one JSON value that satisfies the following JSON Schema.",
|
|
179
|
+
"Do not wrap the JSON in markdown fences. Do not include explanatory text.",
|
|
180
|
+
outputName ? `Schema name: ${outputName}` : "",
|
|
181
|
+
JSON.stringify(schema, null, 2),
|
|
182
|
+
].filter(Boolean).join("\n");
|
|
183
|
+
const repairInstruction = (failure) => [
|
|
184
|
+
"The previous response did not match the requested structured output.",
|
|
185
|
+
failure.message,
|
|
186
|
+
failure.details ? JSON.stringify(failure.details, null, 2) : "",
|
|
187
|
+
"Return only corrected JSON that satisfies the schema.",
|
|
188
|
+
].filter(Boolean).join("\n");
|
|
189
|
+
const resultToTurn = (result) => {
|
|
190
|
+
const toolCalls = result.message.content
|
|
191
|
+
.filter((block) => block.type === "tool_call")
|
|
192
|
+
.map((block) => ({
|
|
193
|
+
callId: block.id,
|
|
194
|
+
name: block.name,
|
|
195
|
+
args: block.args,
|
|
196
|
+
}));
|
|
197
|
+
return {
|
|
198
|
+
message: result.message,
|
|
199
|
+
usage: result.usage,
|
|
200
|
+
stopReason: result.message.stopReason,
|
|
201
|
+
toolCalls,
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
const unsupportedToolError = (message) => new StructuredOutputError(message, "unsupported_tool");
|
|
205
|
+
const validateStructuredTools = (tools) => {
|
|
206
|
+
for (const tool of tools) {
|
|
207
|
+
if (tool.kind !== "server") {
|
|
208
|
+
throw unsupportedToolError("nessi.structured() only accepts server tools. Use nessi() for client tools.");
|
|
209
|
+
}
|
|
210
|
+
if (tool.def.needsApproval) {
|
|
211
|
+
throw unsupportedToolError(`nessi.structured() does not support approval tools: ${tool.def.name}`);
|
|
212
|
+
}
|
|
213
|
+
if (tool.def.name === SUBMIT_RESULT_TOOL_NAME) {
|
|
214
|
+
throw unsupportedToolError(`Tool name "${SUBMIT_RESULT_TOOL_NAME}" is reserved by nessi.structured().`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
const wrapStructuredTool = (tool) => ({
|
|
219
|
+
kind: "server",
|
|
220
|
+
def: tool.def,
|
|
221
|
+
execute(input, ctx) {
|
|
222
|
+
return tool.execute(input, {
|
|
223
|
+
signal: ctx.signal,
|
|
224
|
+
async requestApproval() {
|
|
225
|
+
throw new Error("nessi.structured() does not support tool approvals. Use nessi() for interactive tools.");
|
|
226
|
+
},
|
|
227
|
+
async requestClientTool() {
|
|
228
|
+
throw new Error("nessi.structured() does not support client tool bridges. Use nessi() for interactive tools.");
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
const directStructured = async (options, inputMessage, jsonSchema, loopId) => {
|
|
234
|
+
const startedAt = Date.now();
|
|
235
|
+
let generationMs = 0;
|
|
236
|
+
const completeWithTiming = async (request) => {
|
|
237
|
+
const requestStartedAt = Date.now();
|
|
238
|
+
try {
|
|
239
|
+
return await options.provider.complete(request);
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
generationMs += Math.max(0, Date.now() - requestStartedAt);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
const useResponseFormat = options.provider.capabilities.structuredOutput === true
|
|
246
|
+
&& isStrictNativeSchemaCompatible(jsonSchema);
|
|
247
|
+
const responseFormat = useResponseFormat ? createResponseFormat(jsonSchema, options.outputName) : undefined;
|
|
248
|
+
const systemPrompt = useResponseFormat
|
|
249
|
+
? (options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT)
|
|
250
|
+
: [
|
|
251
|
+
options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT,
|
|
252
|
+
schemaInstruction(jsonSchema, options.outputName),
|
|
253
|
+
].join("\n\n");
|
|
254
|
+
const attempts = [];
|
|
255
|
+
const first = await completeWithTiming({
|
|
256
|
+
systemPrompt,
|
|
257
|
+
messages: [inputMessage],
|
|
258
|
+
responseFormat,
|
|
259
|
+
temperature: options.temperature,
|
|
260
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
261
|
+
disableReasoning: options.disableReasoning,
|
|
262
|
+
signal: options.signal,
|
|
263
|
+
});
|
|
264
|
+
attempts.push(first);
|
|
265
|
+
let parsed = parseStructuredOutput(options.output, textFromMessage(first.message));
|
|
266
|
+
if (!parsed.ok) {
|
|
267
|
+
const repair = await completeWithTiming({
|
|
268
|
+
systemPrompt: [
|
|
269
|
+
options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT,
|
|
270
|
+
schemaInstruction(jsonSchema, options.outputName),
|
|
271
|
+
].join("\n\n"),
|
|
272
|
+
messages: [
|
|
273
|
+
inputMessage,
|
|
274
|
+
first.message,
|
|
275
|
+
{ role: "user", content: [{ type: "text", text: repairInstruction(parsed) }] },
|
|
276
|
+
],
|
|
277
|
+
responseFormat,
|
|
278
|
+
temperature: options.temperature,
|
|
279
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
280
|
+
disableReasoning: options.disableReasoning,
|
|
281
|
+
signal: options.signal,
|
|
282
|
+
});
|
|
283
|
+
attempts.push(repair);
|
|
284
|
+
parsed = parseStructuredOutput(options.output, textFromMessage(repair.message));
|
|
285
|
+
}
|
|
286
|
+
const aggregate = aggregateFromTurns(attempts.map(resultToTurn));
|
|
287
|
+
aggregate.timing = buildLoopTiming({
|
|
288
|
+
wallMs: Math.max(0, Date.now() - startedAt),
|
|
289
|
+
generationMs,
|
|
290
|
+
toolExecutionMs: 0,
|
|
291
|
+
actionWaitMs: 0,
|
|
292
|
+
}, aggregate.usage);
|
|
293
|
+
if (!parsed.ok) {
|
|
294
|
+
throw new StructuredOutputError("Provider returned invalid structured output.", "invalid_output", {
|
|
295
|
+
attempts: attempts.length,
|
|
296
|
+
failure: parsed,
|
|
297
|
+
aggregate,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
const last = attempts[attempts.length - 1];
|
|
301
|
+
const repaired = attempts.length > 1;
|
|
302
|
+
const meta = {
|
|
303
|
+
mode: repaired ? "repair" : useResponseFormat ? "native" : "fallback",
|
|
304
|
+
repaired,
|
|
305
|
+
attempts: attempts.length,
|
|
306
|
+
usedResponseFormat: useResponseFormat,
|
|
307
|
+
};
|
|
308
|
+
return {
|
|
309
|
+
output: parsed.value,
|
|
310
|
+
message: last.message,
|
|
311
|
+
aggregate,
|
|
312
|
+
reason: "stop",
|
|
313
|
+
loopId,
|
|
314
|
+
usage: aggregate.usage,
|
|
315
|
+
providerMeta: last.providerMeta,
|
|
316
|
+
structuredMeta: meta,
|
|
317
|
+
};
|
|
318
|
+
};
|
|
319
|
+
const loopErrorCode = (reason) => {
|
|
320
|
+
if (reason === "max_turns")
|
|
321
|
+
return "max_turns";
|
|
322
|
+
if (reason === "aborted")
|
|
323
|
+
return "aborted";
|
|
324
|
+
return "loop_failed";
|
|
325
|
+
};
|
|
326
|
+
const toolLoopStructured = async (options, inputMessage, jsonSchema, loopId) => {
|
|
327
|
+
const tools = options.tools ?? [];
|
|
328
|
+
validateStructuredTools(tools);
|
|
329
|
+
let submitted;
|
|
330
|
+
const submitDef = defineTool({
|
|
331
|
+
name: SUBMIT_RESULT_TOOL_NAME,
|
|
332
|
+
description: "Submit the final structured result. Call this exactly once when the task is complete.",
|
|
333
|
+
inputSchema: options.output,
|
|
334
|
+
outputSchema: z.object({ accepted: z.literal(true) }),
|
|
335
|
+
});
|
|
336
|
+
const submitTool = submitDef.server(async (input) => {
|
|
337
|
+
submitted = input;
|
|
338
|
+
return { accepted: true };
|
|
339
|
+
});
|
|
340
|
+
submitTool.def.terminal = true;
|
|
341
|
+
const store = memoryStore();
|
|
342
|
+
await store.append(inputMessage);
|
|
343
|
+
const loop = nessi({
|
|
344
|
+
agentId: options.agentId,
|
|
345
|
+
loopId,
|
|
346
|
+
provider: options.provider,
|
|
347
|
+
store,
|
|
348
|
+
systemPrompt: [
|
|
349
|
+
options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT,
|
|
350
|
+
"Use the available tools when needed.",
|
|
351
|
+
`When the final answer is ready, call ${SUBMIT_RESULT_TOOL_NAME} with the structured result.`,
|
|
352
|
+
"Do not return the final structured result as normal assistant text.",
|
|
353
|
+
schemaInstruction(jsonSchema, options.outputName),
|
|
354
|
+
].join("\n\n"),
|
|
355
|
+
tools: [...tools.map(wrapStructuredTool), submitTool],
|
|
356
|
+
maxTurns: options.maxTurns ?? 8,
|
|
357
|
+
temperature: options.temperature,
|
|
358
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
359
|
+
disableReasoning: options.disableReasoning,
|
|
360
|
+
signal: options.signal,
|
|
361
|
+
});
|
|
362
|
+
let loopEnd;
|
|
363
|
+
let message;
|
|
364
|
+
for await (const event of loop) {
|
|
365
|
+
options.onEvent?.(event);
|
|
366
|
+
if (event.type === "turn_end")
|
|
367
|
+
message = event.message;
|
|
368
|
+
if (event.type === "loop_end")
|
|
369
|
+
loopEnd = event;
|
|
370
|
+
}
|
|
371
|
+
if (!loopEnd) {
|
|
372
|
+
throw new StructuredOutputError("Structured tool loop ended without loop_end.", "loop_failed");
|
|
373
|
+
}
|
|
374
|
+
if (loopEnd.reason !== "stop") {
|
|
375
|
+
throw new StructuredOutputError(`Structured tool loop ended with reason: ${loopEnd.reason}`, loopErrorCode(loopEnd.reason), {
|
|
376
|
+
aggregate: loopEnd.aggregate,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
if (submitted === undefined) {
|
|
380
|
+
throw new StructuredOutputError("Structured tool loop ended without a submitted result.", "invalid_output", {
|
|
381
|
+
aggregate: loopEnd.aggregate,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
const lastMessage = message ?? loopEnd.aggregate.turns.at(-1)?.message;
|
|
385
|
+
if (!lastMessage) {
|
|
386
|
+
throw new StructuredOutputError("Structured tool loop ended without an assistant message.", "loop_failed", {
|
|
387
|
+
aggregate: loopEnd.aggregate,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
return {
|
|
391
|
+
output: submitted,
|
|
392
|
+
message: lastMessage,
|
|
393
|
+
aggregate: loopEnd.aggregate,
|
|
394
|
+
reason: loopEnd.reason,
|
|
395
|
+
loopId,
|
|
396
|
+
usage: loopEnd.aggregate.usage,
|
|
397
|
+
structuredMeta: {
|
|
398
|
+
mode: "tool_loop",
|
|
399
|
+
repaired: loopEnd.aggregate.toolErrorCount > 0,
|
|
400
|
+
attempts: loopEnd.aggregate.assistantMessageCount,
|
|
401
|
+
usedResponseFormat: false,
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
};
|
|
405
|
+
export const structured = async (options) => {
|
|
406
|
+
const loopId = options.loopId?.trim() ? options.loopId : createLoopId();
|
|
407
|
+
const inputMessage = normalizeStructuredInput(options.input);
|
|
408
|
+
const jsonSchema = schemaFor(options.output);
|
|
409
|
+
const hasTools = (options.tools?.length ?? 0) > 0;
|
|
410
|
+
if (hasTools)
|
|
411
|
+
return toolLoopStructured(options, inputMessage, jsonSchema, loopId);
|
|
412
|
+
return directStructured(options, inputMessage, jsonSchema, loopId);
|
|
413
|
+
};
|
package/tools.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { ToolDefinition, ServerTool, ClientTool } from "./types.js";
|
|
3
|
+
export declare const defineTool: <TInput extends z.ZodType, TOutput extends z.ZodType = z.ZodAny>(config: {
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
inputSchema: TInput;
|
|
7
|
+
outputSchema?: TOutput;
|
|
8
|
+
needsApproval?: boolean;
|
|
9
|
+
timeoutMs?: number | false;
|
|
10
|
+
toHistoricalResult?: (context: {
|
|
11
|
+
input: z.infer<TInput>;
|
|
12
|
+
output: z.infer<TOutput>;
|
|
13
|
+
callId: string;
|
|
14
|
+
}) => unknown | Promise<unknown>;
|
|
15
|
+
}) => ToolDefinition<TInput, TOutput>;
|
|
16
|
+
export declare const toolToJsonSchema: (tool: ServerTool | ClientTool) => {
|
|
17
|
+
name: string;
|
|
18
|
+
description: string;
|
|
19
|
+
parameters: z.core.ZodStandardJSONSchemaPayload<z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>>;
|
|
20
|
+
};
|
|
21
|
+
export declare const toolToSpec: (tool: ServerTool | ClientTool) => {
|
|
22
|
+
name: string;
|
|
23
|
+
description: string;
|
|
24
|
+
inputSchema: z.core.ZodStandardJSONSchemaPayload<z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>>;
|
|
25
|
+
};
|
package/tools.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// nessi – Tool Definitions
|
|
3
|
+
// ============================================================================
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
// ----------------------------------------------------------------------------
|
|
6
|
+
// defineTool()
|
|
7
|
+
// ----------------------------------------------------------------------------
|
|
8
|
+
export const defineTool = (config) => {
|
|
9
|
+
const def = {
|
|
10
|
+
...config,
|
|
11
|
+
needsApproval: config.needsApproval ?? false,
|
|
12
|
+
server(execute) {
|
|
13
|
+
return { kind: "server", def, execute };
|
|
14
|
+
},
|
|
15
|
+
client(execute) {
|
|
16
|
+
return { kind: "client", def, execute };
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
return def;
|
|
20
|
+
};
|
|
21
|
+
// ----------------------------------------------------------------------------
|
|
22
|
+
// Tool → JSON Schema (for provider adapters)
|
|
23
|
+
// ----------------------------------------------------------------------------
|
|
24
|
+
export const toolToJsonSchema = (tool) => ({
|
|
25
|
+
name: tool.def.name,
|
|
26
|
+
description: tool.def.description,
|
|
27
|
+
parameters: z.toJSONSchema(tool.def.inputSchema, { target: "draft-07" }),
|
|
28
|
+
});
|
|
29
|
+
export const toolToSpec = (tool) => {
|
|
30
|
+
const schema = toolToJsonSchema(tool);
|
|
31
|
+
return {
|
|
32
|
+
name: schema.name,
|
|
33
|
+
description: schema.description,
|
|
34
|
+
inputSchema: schema.parameters,
|
|
35
|
+
};
|
|
36
|
+
};
|