@homeflare/seat-runtime 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/LICENSE +21 -0
- package/README.md +200 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +508 -0
- package/dist/index.js.map +21 -0
- package/dist/mcp-connect.d.ts +72 -0
- package/dist/mcp-connect.d.ts.map +1 -0
- package/dist/mcp-error.d.ts +77 -0
- package/dist/mcp-error.d.ts.map +1 -0
- package/dist/mcp-pages.d.ts +18 -0
- package/dist/mcp-pages.d.ts.map +1 -0
- package/dist/mcp-render.d.ts +25 -0
- package/dist/mcp-render.d.ts.map +1 -0
- package/dist/mcp-tool.d.ts +61 -0
- package/dist/mcp-tool.d.ts.map +1 -0
- package/dist/mcp-toolkit.d.ts +61 -0
- package/dist/mcp-toolkit.d.ts.map +1 -0
- package/dist/mcp-toolset.d.ts +33 -0
- package/dist/mcp-toolset.d.ts.map +1 -0
- package/dist/rounds.d.ts +108 -0
- package/dist/rounds.d.ts.map +1 -0
- package/dist/seat-model.d.ts +59 -0
- package/dist/seat-model.d.ts.map +1 -0
- package/dist/seat-obs.d.ts +23 -0
- package/dist/seat-obs.d.ts.map +1 -0
- package/dist/seat-state.d.ts +33 -0
- package/dist/seat-state.d.ts.map +1 -0
- package/dist/stamp.d.ts +23 -0
- package/dist/stamp.d.ts.map +1 -0
- package/dist/state-dsn.d.ts +41 -0
- package/dist/state-dsn.d.ts.map +1 -0
- package/dist/state-postgres.d.ts +58 -0
- package/dist/state-postgres.d.ts.map +1 -0
- package/dist/state-valkey-connection.d.ts +49 -0
- package/dist/state-valkey-connection.d.ts.map +1 -0
- package/dist/state-valkey-scrub.d.ts +37 -0
- package/dist/state-valkey-scrub.d.ts.map +1 -0
- package/dist/state-valkey-send.d.ts +35 -0
- package/dist/state-valkey-send.d.ts.map +1 -0
- package/dist/state-valkey.d.ts +83 -0
- package/dist/state-valkey.d.ts.map +1 -0
- package/dist/state.d.ts +9 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +327 -0
- package/dist/state.js.map +16 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/docs/mcp.md +40 -0
- package/docs/pairing.md +39 -0
- package/docs/state.md +174 -0
- package/package.json +45 -0
- package/src/index.ts +25 -0
- package/src/mcp-connect.ts +172 -0
- package/src/mcp-error.ts +150 -0
- package/src/mcp-pages.ts +37 -0
- package/src/mcp-render.ts +67 -0
- package/src/mcp-tool.ts +101 -0
- package/src/mcp-toolkit.ts +183 -0
- package/src/mcp-toolset.ts +91 -0
- package/src/rounds.ts +211 -0
- package/src/seat-model.ts +94 -0
- package/src/seat-obs.ts +103 -0
- package/src/seat-state.ts +53 -0
- package/src/stamp.ts +88 -0
- package/src/state-dsn.ts +112 -0
- package/src/state-postgres.ts +114 -0
- package/src/state-valkey-connection.ts +113 -0
- package/src/state-valkey-scrub.ts +60 -0
- package/src/state-valkey-send.ts +67 -0
- package/src/state-valkey.ts +193 -0
- package/src/state.ts +8 -0
- package/src/version.ts +2 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An MCP tool result as the text a model reads.
|
|
3
|
+
*
|
|
4
|
+
* ★ ALWAYS A STRING. A result is a list of content blocks (text, image, audio, a resource, a
|
|
5
|
+
* link), and what goes into the model's context is one message. Text is passed through
|
|
6
|
+
* verbatim, joined by newlines; everything else becomes a one-line marker naming what it was.
|
|
7
|
+
* ⛔ A BINARY BLOCK NEVER REACHES THE MODEL AS ITS BYTES. An image's base64 is tens of
|
|
8
|
+
* kilobytes of context that says nothing to a text model and costs every later round; the
|
|
9
|
+
* marker keeps its type and MIME type so the model knows something was there.
|
|
10
|
+
* ⚠️ AN EMPTY RESULT IS NOT AN EMPTY STRING. Some providers refuse a tool message with empty
|
|
11
|
+
* content (not measured against cf-code), and a refused request ends the whole run over a
|
|
12
|
+
* tool that merely had nothing to say.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** What the model reads for a tool that returned no content blocks. */
|
|
16
|
+
export const EMPTY_RESULT = '(no content)';
|
|
17
|
+
|
|
18
|
+
type Block = Readonly<Record<string, unknown>>;
|
|
19
|
+
|
|
20
|
+
const isBlock = (value: unknown): value is Block => typeof value === 'object' && value !== null;
|
|
21
|
+
const str = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined);
|
|
22
|
+
|
|
23
|
+
/** One block. The shapes are MCP's `TextContent`, `ImageContent`, `AudioContent`, `ResourceLink`, `EmbeddedResource`. */
|
|
24
|
+
function renderBlock(block: Block): string {
|
|
25
|
+
const type = str(block['type']) ?? 'unknown';
|
|
26
|
+
switch (type) {
|
|
27
|
+
case 'text':
|
|
28
|
+
return str(block['text']) ?? '';
|
|
29
|
+
case 'image':
|
|
30
|
+
case 'audio':
|
|
31
|
+
return `[${type}: ${str(block['mimeType']) ?? 'unknown type'}, not shown]`;
|
|
32
|
+
case 'resource_link':
|
|
33
|
+
return `[resource link: ${str(block['uri']) ?? 'no uri'}]`;
|
|
34
|
+
case 'resource': {
|
|
35
|
+
const resource = isBlock(block['resource']) ? block['resource'] : {};
|
|
36
|
+
// An embedded resource is either text (shown) or a base64 blob (not).
|
|
37
|
+
return str(resource['text']) ?? `[resource: ${str(resource['uri']) ?? 'no uri'}, not shown]`;
|
|
38
|
+
}
|
|
39
|
+
default:
|
|
40
|
+
return `[unsupported content block: ${type}]`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The text of a `CallToolResult.content`. Anything that is not a block list is JSON, not dropped. */
|
|
45
|
+
export function renderContent(content: unknown): string {
|
|
46
|
+
if (!Array.isArray(content)) {
|
|
47
|
+
return content === undefined ? EMPTY_RESULT : JSON.stringify(content);
|
|
48
|
+
}
|
|
49
|
+
const lines = content.map((block: unknown) =>
|
|
50
|
+
isBlock(block) ? renderBlock(block) : JSON.stringify(block),
|
|
51
|
+
);
|
|
52
|
+
return lines.length === 0 ? EMPTY_RESULT : lines.join('\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The text of a whole `callTool` result. ★ The `content` blocks are what MCP asks a server to
|
|
57
|
+
* always send; a server that sends only `structuredContent` (an `outputSchema` tool that skipped
|
|
58
|
+
* the text copy) would otherwise read as empty, so its JSON is shown instead. The legacy
|
|
59
|
+
* `toolResult` shape (the SDK's compatibility result) is read as content.
|
|
60
|
+
*/
|
|
61
|
+
export function renderResult(result: Readonly<Record<string, unknown>>): string {
|
|
62
|
+
const content = 'content' in result ? result['content'] : result['toolResult'];
|
|
63
|
+
if (Array.isArray(content) && content.length === 0 && result['structuredContent'] !== undefined) {
|
|
64
|
+
return JSON.stringify(result['structuredContent']);
|
|
65
|
+
}
|
|
66
|
+
return renderContent(content);
|
|
67
|
+
}
|
package/src/mcp-tool.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One MCP tool as an Effect AI dynamic tool: the server's own JSON Schema goes to the model,
|
|
3
|
+
* and the tool call the model sends back decodes without losing its arguments.
|
|
4
|
+
*
|
|
5
|
+
* ★ `Tool.dynamic` WITH THE SERVER'S JSON SCHEMA IS THE RIGHT SHAPE (its docs name MCP tools
|
|
6
|
+
* discovered at runtime), and the model sees that schema verbatim.
|
|
7
|
+
* 🔴 BUT @effect/ai-openai-compat rc.115 CANNOT DECODE A TOOL CALL FOR IT. Measured 2026-09-29
|
|
8
|
+
* (tests/seat-loop.test.ts, first run): the request goes out, and when the model's reply asks
|
|
9
|
+
* for the tool, `transformToolCallParams` runs the OpenAI structured-output codec over the
|
|
10
|
+
* tool's `parametersSchema` — `Schema.Unknown` in JSON-Schema mode — and fails the whole turn
|
|
11
|
+
* with `UnsupportedSchemaError: Root JSON Schema must have type "object" and must not use
|
|
12
|
+
* "anyOf"`. rc.118 fixed it upstream: it returns the raw params for a dynamic tool that has a
|
|
13
|
+
* `jsonSchema`. The estate is pinned at rc.115 and rc.118 drops the `unstable/` import prefix,
|
|
14
|
+
* so this file carries the workaround until the pin moves.
|
|
15
|
+
* ★ THE WORKAROUND: keep `jsonSchema` (what the model is sent, and what `Tool.getJsonSchema`
|
|
16
|
+
* returns), and replace `parametersSchema` — what compat's codec and the Toolkit decode with —
|
|
17
|
+
* by an object schema of the DECLARED property names, each `Unknown`, decoded to `Unknown`
|
|
18
|
+
* with an ENCODE THAT IS FORBIDDEN. Every declared value passes through untouched and
|
|
19
|
+
* `required` is honoured, so a missing argument fails as a tool result the model can read
|
|
20
|
+
* instead of reaching the server.
|
|
21
|
+
* 🔴 WHY THE ENCODE IS FORBIDDEN. compat's `transformToolCallParams` (OpenAiLanguageModel.js)
|
|
22
|
+
* decodes the model's params through the OpenAI structured-output codec and re-encodes them
|
|
23
|
+
* with `parametersSchema`, falling back to the params AS SENT when either step fails. That
|
|
24
|
+
* codec rewrites every optional property as nullable and reads `null` as ABSENT, so with a
|
|
25
|
+
* plain object schema an explicit `null` on an optional argument was deleted before the call.
|
|
26
|
+
* Measured 2026-09-29 (review of PR 328), end to end through `SeatModel` and `mcpToolkit` into
|
|
27
|
+
* an Effect `McpServer`: `update_issue {id, assignee: null}` (null unassigns, omitted leaves
|
|
28
|
+
* alone) reached the server as `{id}`, and the run ended 'done'. That normalisation exists for
|
|
29
|
+
* the codec's own JSON Schema, and the model here was sent the server's, where `null` is a
|
|
30
|
+
* value. Forbidding the encode makes the re-encode fail, so compat forwards the params as the
|
|
31
|
+
* model sent them: `null` arrives as `null` (pinned in tests/mcp-arguments.test.ts).
|
|
32
|
+
* ⚠️ WHAT IT COSTS: a property the schema does not declare is DROPPED before the call
|
|
33
|
+
* (Effect's decoder ignores excess keys; v4 has no "preserve"), so a server that declares
|
|
34
|
+
* `additionalProperties: true` and relies on undeclared keys gets fewer than the model sent.
|
|
35
|
+
* And a tool that declares no properties takes no arguments: `Tool.EmptyParams` is the only
|
|
36
|
+
* root the codec accepts for it, and it rejects any key.
|
|
37
|
+
* ⛔ REMOVE THIS WHEN THE PIN REACHES rc.118 or later: build the tool from `Tool.dynamic` alone.
|
|
38
|
+
* The clone below copies what `Tool`'s own `setParameters` copies (its prototype and own
|
|
39
|
+
* fields), so it depends on Effect's tool object layout; the end-to-end test is what fails
|
|
40
|
+
* first if a bump changes that.
|
|
41
|
+
*/
|
|
42
|
+
import type * as JsonSchema from 'effect/JsonSchema';
|
|
43
|
+
import * as Schema from 'effect/Schema';
|
|
44
|
+
import * as SchemaGetter from 'effect/SchemaGetter';
|
|
45
|
+
import * as Tool from 'effect/unstable/ai/Tool';
|
|
46
|
+
|
|
47
|
+
/** One dynamic tool per MCP tool: the server's JSON Schema in, text out, failures returned. */
|
|
48
|
+
export type McpTool = Tool.Dynamic<
|
|
49
|
+
string,
|
|
50
|
+
{
|
|
51
|
+
readonly parameters: JsonSchema.JsonSchema;
|
|
52
|
+
readonly success: typeof Schema.String;
|
|
53
|
+
readonly failure: typeof Schema.String;
|
|
54
|
+
readonly failureMode: 'return';
|
|
55
|
+
}
|
|
56
|
+
>;
|
|
57
|
+
|
|
58
|
+
/** What `client.listTools()` gives for one tool, narrowed to what is used here. */
|
|
59
|
+
export type McpToolSpec = {
|
|
60
|
+
readonly name: string;
|
|
61
|
+
readonly description?: string | undefined;
|
|
62
|
+
readonly inputSchema: JsonSchema.JsonSchema;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The workaround's `parametersSchema`: the declared property names, each passed through as-is, decode-only. */
|
|
66
|
+
export function declaredParameters(inputSchema: JsonSchema.JsonSchema): Schema.Constraint {
|
|
67
|
+
const properties = inputSchema['properties'];
|
|
68
|
+
const names =
|
|
69
|
+
typeof properties === 'object' && properties !== null ? Object.keys(properties) : [];
|
|
70
|
+
if (names.length === 0) return Tool.EmptyParams;
|
|
71
|
+
const required = new Set(
|
|
72
|
+
Array.isArray(inputSchema['required']) ? (inputSchema['required'] as unknown[]) : [],
|
|
73
|
+
);
|
|
74
|
+
const declared = Schema.Struct(
|
|
75
|
+
Object.fromEntries(
|
|
76
|
+
names.map((name) => [
|
|
77
|
+
name,
|
|
78
|
+
required.has(name) ? Schema.Unknown : Schema.optional(Schema.Unknown),
|
|
79
|
+
]),
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
return declared.pipe(
|
|
83
|
+
Schema.decodeTo(Schema.Unknown, {
|
|
84
|
+
decode: SchemaGetter.passthrough(),
|
|
85
|
+
encode: SchemaGetter.forbiddenEncoding,
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function mcpTool(spec: McpToolSpec): McpTool {
|
|
91
|
+
const dynamic = Tool.dynamic(spec.name, {
|
|
92
|
+
description: spec.description,
|
|
93
|
+
parameters: spec.inputSchema,
|
|
94
|
+
success: Schema.String,
|
|
95
|
+
failure: Schema.String,
|
|
96
|
+
failureMode: 'return',
|
|
97
|
+
});
|
|
98
|
+
return Object.assign(Object.create(Object.getPrototypeOf(dynamic)), dynamic, {
|
|
99
|
+
parametersSchema: declaredParameters(spec.inputSchema),
|
|
100
|
+
}) as McpTool;
|
|
101
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An MCP server's tools as an Effect AI `Toolkit`, and its resources as two Effects, through
|
|
3
|
+
* the official MCP TypeScript SDK (`Client` over `StreamableHTTPClientTransport`).
|
|
4
|
+
*
|
|
5
|
+
* ★ THE SDK'S CLIENT IS THE CLIENT. Effect AI ships the SERVER half of MCP (`McpServer`,
|
|
6
|
+
* `McpSchema`); no client (walked down 2026-09-29, docs/plans/2026-09-29-cf-seat-sdk-spike.md
|
|
7
|
+
* open item 2). Wrapping the official client as dynamic tools is that plan's smallest option,
|
|
8
|
+
* and the scout measured the pairing: list, call and resource read against an Effect
|
|
9
|
+
* `McpServer` (pair115/mcp.ts).
|
|
10
|
+
* ★ EACH MCP TOOL IS A `Tool.dynamic` WITH THE SERVER'S OWN JSON SCHEMA (mcp-tool.ts). The schema
|
|
11
|
+
* goes to the model as the server wrote it, and no Effect Schema is built from its types: the
|
|
12
|
+
* only client-side check is that the declared `required` arguments are present, and the server
|
|
13
|
+
* validates the rest and its complaint comes back to the model.
|
|
14
|
+
* ⚠️ A TOOL FAILURE IS THE MODEL'S TO SEE, NOT THE RUN'S TO DIE OF. Every tool is
|
|
15
|
+
* `failureMode: 'return'`: an `isError` result, a JSON-RPC error (bad arguments, unknown
|
|
16
|
+
* tool) and a call that never completed all come back to the model as the tool's result, so
|
|
17
|
+
* it can correct itself or answer without the tool. MCP specifies errors-in-results for
|
|
18
|
+
* exactly this. Connecting and listing are different: nothing works without them, so they
|
|
19
|
+
* fail with `McpToolkitError`.
|
|
20
|
+
* ⚠️ THE TOOL LIST IS A SNAPSHOT taken at connect time; a server's `listChanged` notification is
|
|
21
|
+
* not followed. Reconnect for a fresh list.
|
|
22
|
+
* ⛔ HEADERS ARE CREDENTIALS. They ride `requestInit` to the server and nowhere else: not in an
|
|
23
|
+
* error, a span or a log (mcp-error.ts). Pass a bearer value as `Redacted` to keep it out of
|
|
24
|
+
* an accidental print of the argument, as `SeatModel` does with the API key.
|
|
25
|
+
*/
|
|
26
|
+
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
27
|
+
import * as Effect from 'effect/Effect';
|
|
28
|
+
import type * as Scope from 'effect/Scope';
|
|
29
|
+
import type * as Toolkit from 'effect/unstable/ai/Toolkit';
|
|
30
|
+
import {
|
|
31
|
+
DEFAULT_CONNECT_TIMEOUT_MS,
|
|
32
|
+
type McpHeaders,
|
|
33
|
+
connectAndBuild,
|
|
34
|
+
headerValues,
|
|
35
|
+
validateTimeout,
|
|
36
|
+
} from './mcp-connect.ts';
|
|
37
|
+
import { McpToolkitError, type Redact, redactor, serverLabel } from './mcp-error.ts';
|
|
38
|
+
import { collect } from './mcp-pages.ts';
|
|
39
|
+
import { type McpTools, toolset } from './mcp-toolset.ts';
|
|
40
|
+
|
|
41
|
+
export type { McpTools } from './mcp-toolset.ts';
|
|
42
|
+
|
|
43
|
+
export type McpResource = {
|
|
44
|
+
readonly uri: string;
|
|
45
|
+
readonly name: string;
|
|
46
|
+
readonly description?: string | undefined;
|
|
47
|
+
readonly mimeType?: string | undefined;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** One entry of a resource read: `text` for text, `blob` (base64) for binary. */
|
|
51
|
+
export type McpResourceContent = {
|
|
52
|
+
readonly uri: string;
|
|
53
|
+
readonly mimeType?: string | undefined;
|
|
54
|
+
readonly text?: string | undefined;
|
|
55
|
+
readonly blob?: string | undefined;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type McpToolkit = {
|
|
59
|
+
/** Every tool the server listed, with handlers that call it: hand this to `runRounds`. */
|
|
60
|
+
readonly toolkit: Toolkit.WithHandler<McpTools>;
|
|
61
|
+
/** The server's resources; empty when it does not advertise the resources capability. */
|
|
62
|
+
readonly listResources: Effect.Effect<ReadonlyArray<McpResource>, McpToolkitError>;
|
|
63
|
+
/** The contents of one resource; fails with `McpToolkitError` for an unknown URI. */
|
|
64
|
+
readonly readResource: (
|
|
65
|
+
uri: string,
|
|
66
|
+
) => Effect.Effect<ReadonlyArray<McpResourceContent>, McpToolkitError>;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type McpToolkitOptions = {
|
|
70
|
+
/**
|
|
71
|
+
* The budget for STARTING UP, in milliseconds; default 15 000. A finite number above 0 and at
|
|
72
|
+
* most 2 ** 31 - 1, or a `RangeError` defect before any request (mcp-connect.ts).
|
|
73
|
+
* It covers, each with the full budget:
|
|
74
|
+
* - the WHOLE handshake: `initialize` and the `notifications/initialized` that follows it;
|
|
75
|
+
* - every `tools/list` page read after it (a server holding one fails the call as
|
|
76
|
+
* `McpToolkitError` with `operation: 'listTools'`).
|
|
77
|
+
* ⚠️ It is a budget PER REQUEST, not a total: a server that answers each of its (up to 100)
|
|
78
|
+
* tool pages in just under the budget can still take that many budgets. Wrap the whole call
|
|
79
|
+
* in `Effect.timeout` for a total.
|
|
80
|
+
* ⚠️ The SDK bounds only the `initialize` request (its default is 60 s) and leaves the
|
|
81
|
+
* notification unbounded, so a server that answers `initialize` and then stalls would hold a
|
|
82
|
+
* seat at startup indefinitely. Here the budget covers both, and the handshake and the
|
|
83
|
+
* listing are interruptible, so a caller's `Effect.timeout` or a shutdown also ends them, and
|
|
84
|
+
* either way the client is closed and nothing is left in your scope (mcp-connect.ts).
|
|
85
|
+
* ⚠️ NOT COVERED: `listResources`, `readResource` and tool calls, which are requests you make
|
|
86
|
+
* after startup and keep the SDK's 60 s default per request. They are interruptible too.
|
|
87
|
+
*/
|
|
88
|
+
readonly connectTimeoutMs?: number | undefined;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** The two resource operations against a connected client; neither runs at connect time. */
|
|
92
|
+
function resourcesOf(client: Client, server: string, redact: Redact) {
|
|
93
|
+
const listResources = Effect.suspend(() =>
|
|
94
|
+
// ★ A server that does not advertise resources is not asked: a strict one answers
|
|
95
|
+
// "method not found", and "no resources" is the truthful reading of that.
|
|
96
|
+
client.getServerCapabilities()?.resources === undefined
|
|
97
|
+
? Effect.succeed<ReadonlyArray<McpResource>>([])
|
|
98
|
+
: collect(
|
|
99
|
+
'listResources',
|
|
100
|
+
server,
|
|
101
|
+
async (cursor, signal) => {
|
|
102
|
+
const page = await client.listResources(cursor === undefined ? undefined : { cursor }, {
|
|
103
|
+
signal,
|
|
104
|
+
});
|
|
105
|
+
return {
|
|
106
|
+
items: page.resources.map((resource): McpResource => ({
|
|
107
|
+
uri: resource.uri,
|
|
108
|
+
name: resource.name,
|
|
109
|
+
description: resource.description,
|
|
110
|
+
mimeType: resource.mimeType,
|
|
111
|
+
})),
|
|
112
|
+
next: page.nextCursor,
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
redact,
|
|
116
|
+
),
|
|
117
|
+
).pipe(Effect.withSpan('seat.mcp.list_resources', { attributes: { 'server.address': server } }));
|
|
118
|
+
|
|
119
|
+
const readResource = (uri: string) =>
|
|
120
|
+
Effect.tryPromise({
|
|
121
|
+
try: async (signal): Promise<ReadonlyArray<McpResourceContent>> => {
|
|
122
|
+
const read = await client.readResource({ uri }, { signal });
|
|
123
|
+
return read.contents.map((entry) => ({
|
|
124
|
+
uri: entry.uri,
|
|
125
|
+
mimeType: entry.mimeType,
|
|
126
|
+
text: 'text' in entry ? entry.text : undefined,
|
|
127
|
+
blob: 'blob' in entry ? entry.blob : undefined,
|
|
128
|
+
}));
|
|
129
|
+
},
|
|
130
|
+
catch: (cause) => new McpToolkitError({ operation: 'readResource', server, cause, redact }),
|
|
131
|
+
}).pipe(
|
|
132
|
+
Effect.withSpan('seat.mcp.read_resource', {
|
|
133
|
+
attributes: { 'mcp.resource': uri, 'server.address': server },
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
return { listResources, readResource };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Connect to a Streamable HTTP MCP server, list its tools, and return them as a toolkit.
|
|
142
|
+
* The connection lives as long as the surrounding `Scope`.
|
|
143
|
+
*
|
|
144
|
+
* ★ A FAILURE AT ANY POINT LEAVES THE CALLER'S SCOPE EMPTY: the handshake, the `tools/list`
|
|
145
|
+
* (a JSON-RPC error, a page held past `connectTimeoutMs`) and building the toolkit all run
|
|
146
|
+
* before the client's finalizer is registered, and each closes the client if it fails or is
|
|
147
|
+
* interrupted (`connectAndBuild`, mcp-connect.ts). So `Effect.retry` around `mcpToolkit`
|
|
148
|
+
* accumulates no clients, sessions or sockets.
|
|
149
|
+
*/
|
|
150
|
+
export function mcpToolkit(
|
|
151
|
+
url: string | URL,
|
|
152
|
+
headers?: McpHeaders,
|
|
153
|
+
options?: McpToolkitOptions,
|
|
154
|
+
): Effect.Effect<McpToolkit, McpToolkitError, Scope.Scope> {
|
|
155
|
+
return Effect.gen(function* () {
|
|
156
|
+
const timeout = yield* validateTimeout(options?.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
|
|
157
|
+
// ⚠️ Parsed INSIDE the Effect: `new URL` throws, and a throw at call time would skip the typed
|
|
158
|
+
// error. The message never repeats the string: a URL is where a token may sit.
|
|
159
|
+
const target = yield* Effect.try({
|
|
160
|
+
try: () => new URL(url),
|
|
161
|
+
catch: () =>
|
|
162
|
+
new McpToolkitError({
|
|
163
|
+
operation: 'connect',
|
|
164
|
+
server: '(unparseable URL)',
|
|
165
|
+
cause: 'the URL did not parse',
|
|
166
|
+
}),
|
|
167
|
+
});
|
|
168
|
+
const server = serverLabel(target);
|
|
169
|
+
// ⛔ Every error and every failure text below goes through this: a server echoes its address,
|
|
170
|
+
// a query value or a header value, and a fetch failure prints the address, query string and
|
|
171
|
+
// all (mcp-error.ts).
|
|
172
|
+
const redact = redactor(target, headerValues(headers));
|
|
173
|
+
|
|
174
|
+
// The connection lives in the surrounding scope; it is registered there only once the whole
|
|
175
|
+
// toolkit below has been built (see the header of `mcpToolkit`).
|
|
176
|
+
return yield* connectAndBuild(target, headers, server, timeout, redact, (client) =>
|
|
177
|
+
Effect.gen(function* () {
|
|
178
|
+
const toolkit = yield* toolset(client, server, timeout, redact);
|
|
179
|
+
return { toolkit, ...resourcesOf(client, server, redact) };
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An MCP server's tools as a ready-to-run Effect AI toolkit: list them (bounded by the startup
|
|
3
|
+
* budget), wrap each as a `Tool.dynamic` (mcp-tool.ts), and give each a handler that calls the
|
|
4
|
+
* server. Split from mcp-toolkit.ts so that `connectAndBuild` can run all of this BEFORE the
|
|
5
|
+
* client's finalizer is registered (mcp-connect.ts): listing can fail, and a failed listing must
|
|
6
|
+
* leave nothing in the caller's scope.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ A TOOL FAILURE IS THE MODEL'S TO SEE, NOT THE RUN'S TO DIE OF (see mcp-toolkit.ts). Every
|
|
9
|
+
* failure below comes back as a string.
|
|
10
|
+
* ⛔ A FAILURE'S TEXT IS REDACTED, A SUCCESS'S IS NOT. What the model reads for an `isError`
|
|
11
|
+
* result, a JSON-RPC error or a dropped call goes through `redact`: a server that rejects a
|
|
12
|
+
* credential typically prints it ("bad credential Bearer ..."), and that text goes on to a
|
|
13
|
+
* cloud model. Measured 2026-09-29 (review of PR 328, later round): an `isError` result that
|
|
14
|
+
* echoed a query value and a bearer token delivered both to the model, while the same echo in
|
|
15
|
+
* an `McpToolkitError` was already redacted. A SUCCESSFUL result is the tool's payload and is
|
|
16
|
+
* delivered verbatim: rewriting it would corrupt legitimate data that happens to contain a
|
|
17
|
+
* value, and a server that returns a credential as data is not a failure this can tell apart.
|
|
18
|
+
*/
|
|
19
|
+
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
20
|
+
import * as Effect from 'effect/Effect';
|
|
21
|
+
import * as Toolkit from 'effect/unstable/ai/Toolkit';
|
|
22
|
+
import { type McpToolkitError, type Redact, describeCause } from './mcp-error.ts';
|
|
23
|
+
import { collect } from './mcp-pages.ts';
|
|
24
|
+
import { renderResult } from './mcp-render.ts';
|
|
25
|
+
import { type McpTool, mcpTool } from './mcp-tool.ts';
|
|
26
|
+
|
|
27
|
+
export type McpTools = Readonly<Record<string, McpTool>>;
|
|
28
|
+
|
|
29
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
30
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
31
|
+
|
|
32
|
+
/** Everything the model can do wrong or the server can refuse comes back as a string. */
|
|
33
|
+
const call = (client: Client, server: string, redact: Redact, name: string) => (params: unknown) =>
|
|
34
|
+
Effect.gen(function* () {
|
|
35
|
+
// The Toolkit's decode has already rejected a non-object (mcp-tool.ts), so this is the
|
|
36
|
+
// type guard the `unknown` argument needs and not a second line of defence.
|
|
37
|
+
if (!isRecord(params)) {
|
|
38
|
+
return yield* Effect.fail(`${name}: the arguments must be a JSON object`);
|
|
39
|
+
}
|
|
40
|
+
const result = yield* Effect.tryPromise({
|
|
41
|
+
try: (signal) => client.callTool({ name, arguments: params }, undefined, { signal }),
|
|
42
|
+
catch: (cause) => `MCP call to ${name} failed: ${redact(describeCause(cause))}`,
|
|
43
|
+
});
|
|
44
|
+
const text = renderResult(result);
|
|
45
|
+
// ⛔ Only a FAILURE's text is redacted: see the header.
|
|
46
|
+
return result.isError === true ? yield* Effect.fail(redact(text)) : text;
|
|
47
|
+
}).pipe(
|
|
48
|
+
Effect.withSpan('seat.mcp.call_tool', {
|
|
49
|
+
attributes: { 'mcp.tool': name, 'server.address': server },
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* List the server's tools and return them as a toolkit with handlers.
|
|
55
|
+
* ★ A server that does not advertise tools is not asked for them: a resources-only server
|
|
56
|
+
* answers `tools/list` with "method not found", and one such server would otherwise make the
|
|
57
|
+
* whole connection unusable for its resources.
|
|
58
|
+
* ★ `timeout` is the startup budget: without it the SDK waits its 60 s per page.
|
|
59
|
+
*/
|
|
60
|
+
export const toolset = (
|
|
61
|
+
client: Client,
|
|
62
|
+
server: string,
|
|
63
|
+
timeout: number,
|
|
64
|
+
redact: Redact,
|
|
65
|
+
): Effect.Effect<Toolkit.WithHandler<McpTools>, McpToolkitError> =>
|
|
66
|
+
Effect.gen(function* () {
|
|
67
|
+
const listed =
|
|
68
|
+
client.getServerCapabilities()?.tools === undefined
|
|
69
|
+
? []
|
|
70
|
+
: yield* collect(
|
|
71
|
+
'listTools',
|
|
72
|
+
server,
|
|
73
|
+
async (cursor, signal) => {
|
|
74
|
+
const page = await client.listTools(cursor === undefined ? undefined : { cursor }, {
|
|
75
|
+
signal,
|
|
76
|
+
timeout,
|
|
77
|
+
});
|
|
78
|
+
return { items: page.tools, next: page.nextCursor };
|
|
79
|
+
},
|
|
80
|
+
redact,
|
|
81
|
+
);
|
|
82
|
+
const tools = listed.map((tool) =>
|
|
83
|
+
mcpTool({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema }),
|
|
84
|
+
);
|
|
85
|
+
const definition = Toolkit.make(...tools);
|
|
86
|
+
const handlers = Object.fromEntries(
|
|
87
|
+
tools.map((tool) => [tool.name, call(client, server, redact, tool.name)]),
|
|
88
|
+
);
|
|
89
|
+
const context = yield* definition.toHandlers(handlers);
|
|
90
|
+
return yield* definition.pipe(Effect.provideContext(context));
|
|
91
|
+
});
|
package/src/rounds.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one round loop every seat shares: model turn, tool results, model turn, until the model
|
|
3
|
+
* stops asking for tools or the cap fires — and a cap that fires is followed by ONE more turn
|
|
4
|
+
* with the toolkit taken away, so a run ends with an answer instead of a truncated transcript.
|
|
5
|
+
*
|
|
6
|
+
* ★ WHAT THE SDK DOES AND DOES NOT DO. `Chat.generateText` with a toolkit resolves the tool
|
|
7
|
+
* calls of ONE model turn and returns; it never re-prompts, and Effect AI ships no
|
|
8
|
+
* `maxRounds` or `stopWhen` (grep of LanguageModel.d.ts and Chat.d.ts, 2026-09-29). The
|
|
9
|
+
* documented multi-round agent is a `while` over `session.generateText({ prompt: [], toolkit })`
|
|
10
|
+
* (compat's ai-docs, 30_chat.ts). This file is that `while`, once, with the cap.
|
|
11
|
+
* ⛔ THE CAP IS HARD AND FINITE. `maxRounds` must be a positive integer: `Infinity`, `0`, a
|
|
12
|
+
* fraction or `NaN` is a programming error and dies with a `RangeError` before any model
|
|
13
|
+
* call, because a loop a bad config can un-cap is a loop that spends until something else
|
|
14
|
+
* stops it (LiteLLM's budget hit the claude2 fleet that way, 2026-09-28).
|
|
15
|
+
* ⚠️ THE FORCED TURN SENDS NO TOOLS. No toolkit, and `toolChoice: 'none'` stated anyway: the SDK
|
|
16
|
+
* already defaults `toolChoice` to `'none'` for a call with no toolkit (LanguageModel.js,
|
|
17
|
+
* `providerOptions`, measured by mutation 2026-09-29: dropping the option changed no test), so
|
|
18
|
+
* it is kept for what it says and for the `toolChoice` attribute on the turn's span. compat
|
|
19
|
+
* then sends neither `tools` nor `tool_choice` (prepareTools returns both undefined for an
|
|
20
|
+
* empty tool list, rc.115), because an OpenAI-shaped API refuses a `tool_choice` with no
|
|
21
|
+
* `tools`. The history still carries the earlier tool calls and results, and cf-code accepted
|
|
22
|
+
* exactly that: one live call through CT100's LiteLLM (:4100), 2026-09-29, a history of
|
|
23
|
+
* system, user, assistant tool call and tool result, no `tools`, `toolChoice: 'none'`, came
|
|
24
|
+
* back `finishReason: 'stop'` with text and no tool call. One sample, one alias.
|
|
25
|
+
* ⚠️ `capped` MEANS THE MODEL STILL WANTED TOOLS after `maxRounds` tool rounds. A model that
|
|
26
|
+
* answers on round `maxRounds` exactly is `capped: false`: the cap did not fire.
|
|
27
|
+
* 🔴 THE FORCED TURN CAN BE REFUSED, AND THEN THE RUN DOES NOT DIE OF IT. A provider that asks for
|
|
28
|
+
* a tool although none was offered (a gateway that adds a dummy tool for a history full of
|
|
29
|
+
* tool calls does exactly that) answers a turn the SDK cannot use, and the SDK says so with one
|
|
30
|
+
* of TWO `AiError` reasons, depending on WHO rejects the tool call:
|
|
31
|
+
* - `ToolNotFoundError`: @effect/ai-openai-compat (`SeatModel`'s provider) rejects it first,
|
|
32
|
+
* while it maps the reply (`transformToolCallParams`: "Tool ... not found. Available tools:
|
|
33
|
+
* none"). Measured 2026-09-29 (review of PR 328, of the round 2 fix), `SeatModel` against a loopback
|
|
34
|
+
* LiteLLM stand-in that answered every call with a tool call: 3 model calls, tools offered
|
|
35
|
+
* [true, true, false], then the whole run failed with every round already spent. Round 2's
|
|
36
|
+
* fix caught only the reason below and so did nothing for this provider.
|
|
37
|
+
* - `InvalidOutputError` raised by `LanguageModel` (module `LanguageModel`): the SDK's own decode
|
|
38
|
+
* of the provider's parts ("Expected ... text | reasoning ..."), reached by a provider that hands
|
|
39
|
+
* the SDK a tool-call part itself (the scripted model in tests/fake-model.ts). The same reason
|
|
40
|
+
* raised by `OpenAiClient` (an empty, truncated or non-completion body, a dropped stream) is NOT
|
|
41
|
+
* a refusal and fails the run (tests/seat-broken-forced.test.ts).
|
|
42
|
+
* Either is caught: the result is `capped` and `unanswered`, its `response` is the LAST TOOL
|
|
43
|
+
* ROUND's (whose calls did run), `rounds` stays `maxRounds`, and a warning, a span error and
|
|
44
|
+
* `seat_rounds_unanswered_total` say what happened. Any OTHER failure of the forced turn
|
|
45
|
+
* (network, rate limit, a handler) still fails the run.
|
|
46
|
+
*/
|
|
47
|
+
import * as Effect from 'effect/Effect';
|
|
48
|
+
import * as Metric from 'effect/Metric';
|
|
49
|
+
import type * as Chat from 'effect/unstable/ai/Chat';
|
|
50
|
+
import type * as LanguageModel from 'effect/unstable/ai/LanguageModel';
|
|
51
|
+
import * as Prompt from 'effect/unstable/ai/Prompt';
|
|
52
|
+
import type * as Tool from 'effect/unstable/ai/Tool';
|
|
53
|
+
import type { AiError } from 'effect/unstable/ai/AiError';
|
|
54
|
+
import type * as Toolkit from 'effect/unstable/ai/Toolkit';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A tool call the forced turn cannot use. `ToolNotFoundError` is compat's own rejection (its reply
|
|
58
|
+
* mapping); `InvalidOutputError` counts ONLY when `LanguageModel` raised it (the SDK's decode of a
|
|
59
|
+
* provider that handed it a tool-call part). compat raises the same reason, from `OpenAiClient`, for
|
|
60
|
+
* a 200 with an empty, non-JSON, truncated or non-completion body and for a body stream that dies
|
|
61
|
+
* mid-read: those are gateway or network failures and must fail the run, not read as a refusal
|
|
62
|
+
* (review of PR 328, round 4; tests/seat-broken-forced.test.ts).
|
|
63
|
+
*/
|
|
64
|
+
const isToolRefusal = (error: AiError): boolean =>
|
|
65
|
+
error.reason._tag === 'ToolNotFoundError' ||
|
|
66
|
+
(error.reason._tag === 'InvalidOutputError' && error.module === 'LanguageModel');
|
|
67
|
+
|
|
68
|
+
/** Every model turn, forced or not. */
|
|
69
|
+
const roundsTotal = Metric.counter('seat_rounds_total', {
|
|
70
|
+
description:
|
|
71
|
+
'Model turns taken by runRounds that returned; a refused forced final turn is not counted.',
|
|
72
|
+
});
|
|
73
|
+
/** Runs whose cap fired: the interesting number, because each one is an agent that did not finish. */
|
|
74
|
+
const roundsCapped = Metric.counter('seat_rounds_capped_total', {
|
|
75
|
+
description: 'runRounds runs that hit maxRounds and were given a forced final turn.',
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/** Runs whose forced final turn was refused: the model still asked for a tool. */
|
|
79
|
+
const roundsUnanswered = Metric.counter('seat_rounds_unanswered_total', {
|
|
80
|
+
description: 'runRounds runs whose forced final turn came back as a tool call, not an answer.',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A turn's response: with the toolkit, or the forced one without.
|
|
85
|
+
* ⚠️ The two modes differ because the SDK's own types do: a call with a toolkit answers
|
|
86
|
+
* `'opaque'` tool parameters, a call without one answers the default `'decoded'`.
|
|
87
|
+
*/
|
|
88
|
+
type Response<Tools extends Record<string, Tool.Any>> =
|
|
89
|
+
| LanguageModel.GenerateTextResponse<Tools, 'opaque'>
|
|
90
|
+
| LanguageModel.GenerateTextResponse<{}, 'decoded'>;
|
|
91
|
+
|
|
92
|
+
/** One model turn, as `onRound` and the result see it. A refused forced final turn is not reported. */
|
|
93
|
+
export type Round<Tools extends Record<string, Tool.Any>> = {
|
|
94
|
+
/** 1-based. The forced final turn, when there is one, is `maxRounds + 1`. */
|
|
95
|
+
readonly round: number;
|
|
96
|
+
/** True only for the forced final turn: no toolkit, `toolChoice: 'none'`. */
|
|
97
|
+
readonly forced: boolean;
|
|
98
|
+
readonly response: Response<Tools>;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type RoundsOptions<Tools extends Record<string, Tool.Any>> = {
|
|
102
|
+
/**
|
|
103
|
+
* The conversation, already holding the opening prompt (`Chat.fromPrompt`) or a resumed
|
|
104
|
+
* history that ends in a user message or tool results. Every round sends an EMPTY prompt:
|
|
105
|
+
* `Chat` appends the model's turn and its tool results to `history` itself.
|
|
106
|
+
*/
|
|
107
|
+
readonly chat: Chat.Chat;
|
|
108
|
+
/**
|
|
109
|
+
* Tools WITH their handlers. A `Toolkit.make(...)` is an Effect that needs its handlers from
|
|
110
|
+
* context: `yield*` it where they are provided, and pass the result. (Typing it as the
|
|
111
|
+
* Effect would hide its requirements, which `generateText` itself cannot track either.)
|
|
112
|
+
*/
|
|
113
|
+
readonly toolkit: Toolkit.WithHandler<Tools>;
|
|
114
|
+
/** The most tool rounds allowed. A positive integer; see the header. */
|
|
115
|
+
readonly maxRounds: number;
|
|
116
|
+
/** After every model turn, the forced one included. Runs inside that turn's span. */
|
|
117
|
+
readonly onRound?: ((round: Round<Tools>) => Effect.Effect<void>) | undefined;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export type RoundsResult<Tools extends Record<string, Tool.Any>> = {
|
|
121
|
+
/** The last model turn: the answer, or the forced final turn when the cap fired. */
|
|
122
|
+
readonly response: Response<Tools>;
|
|
123
|
+
/** Model turns that returned, the forced one included when it did. */
|
|
124
|
+
readonly rounds: number;
|
|
125
|
+
/** The cap fired and `response` is the forced final turn (or see `unanswered`). */
|
|
126
|
+
readonly capped: boolean;
|
|
127
|
+
/**
|
|
128
|
+
* The forced turn was refused (a provider that still asks for a tool) and `response` is the
|
|
129
|
+
* last TOOL round's: it holds no answer, and its tool calls ran. Only ever true with `capped`;
|
|
130
|
+
* `rounds` then counts the turns that returned, so it is `maxRounds`. See the header.
|
|
131
|
+
*/
|
|
132
|
+
readonly unanswered: boolean;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Run the loop. Fails with whatever a model turn fails with (`AiError`, a tool handler's own
|
|
137
|
+
* failure); nothing is retried here — the caller wraps `Effect.retry` where it wants one.
|
|
138
|
+
*/
|
|
139
|
+
export function runRounds<Tools extends Record<string, Tool.Any>>(
|
|
140
|
+
options: RoundsOptions<Tools>,
|
|
141
|
+
): Effect.Effect<
|
|
142
|
+
RoundsResult<Tools>,
|
|
143
|
+
LanguageModel.ExtractError<{ readonly toolkit: Toolkit.WithHandler<Tools> }>,
|
|
144
|
+
| LanguageModel.LanguageModel
|
|
145
|
+
| LanguageModel.ExtractServices<{ readonly toolkit: Toolkit.WithHandler<Tools> }>
|
|
146
|
+
> {
|
|
147
|
+
const { chat, toolkit, maxRounds, onRound } = options;
|
|
148
|
+
return Effect.gen(function* () {
|
|
149
|
+
if (!Number.isInteger(maxRounds) || maxRounds < 1) {
|
|
150
|
+
return yield* Effect.die(
|
|
151
|
+
new RangeError(`runRounds: maxRounds must be a positive integer, got ${String(maxRounds)}`),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const finish = (round: number, forced: boolean) =>
|
|
155
|
+
Effect.fnUntraced(function* (response: Response<Tools>) {
|
|
156
|
+
yield* Metric.update(roundsTotal, 1);
|
|
157
|
+
yield* Effect.annotateCurrentSpan({ 'seat.tool_calls': response.toolCalls.length });
|
|
158
|
+
if (onRound !== undefined) yield* onRound({ round, forced, response });
|
|
159
|
+
return response;
|
|
160
|
+
});
|
|
161
|
+
const span = (round: number, forced: boolean) =>
|
|
162
|
+
Effect.withSpan('seat.round', {
|
|
163
|
+
attributes: { 'seat.round': round, 'seat.round.forced': forced },
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const turn = (round: number) =>
|
|
167
|
+
chat
|
|
168
|
+
.generateText({ prompt: Prompt.empty, toolkit })
|
|
169
|
+
.pipe(Effect.flatMap(finish(round, false)), span(round, false));
|
|
170
|
+
|
|
171
|
+
let round = 1;
|
|
172
|
+
let response: Response<Tools> = yield* turn(round);
|
|
173
|
+
// ★ The exit test is the model's: no tool calls means it has answered.
|
|
174
|
+
while (response.toolCalls.length > 0 && round < maxRounds) {
|
|
175
|
+
round += 1;
|
|
176
|
+
response = yield* turn(round);
|
|
177
|
+
}
|
|
178
|
+
if (response.toolCalls.length === 0) {
|
|
179
|
+
return { response, rounds: round, capped: false, unanswered: false };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
yield* Metric.update(roundsCapped, 1);
|
|
183
|
+
yield* Effect.logWarning('seat round cap reached; forcing a final turn without tools', {
|
|
184
|
+
maxRounds,
|
|
185
|
+
});
|
|
186
|
+
const forcedRound = maxRounds + 1;
|
|
187
|
+
/** The forced turn came back as a tool call: counted and logged, and the run goes on. */
|
|
188
|
+
const refused = () =>
|
|
189
|
+
Effect.as(
|
|
190
|
+
Effect.all([
|
|
191
|
+
Metric.update(roundsUnanswered, 1),
|
|
192
|
+
Effect.logWarning('seat forced final turn refused: the model still asked for a tool', {
|
|
193
|
+
maxRounds,
|
|
194
|
+
}),
|
|
195
|
+
]),
|
|
196
|
+
undefined,
|
|
197
|
+
);
|
|
198
|
+
const forced = yield* chat.generateText({ prompt: Prompt.empty, toolChoice: 'none' }).pipe(
|
|
199
|
+
Effect.flatMap(finish(forcedRound, true)),
|
|
200
|
+
span(forcedRound, true),
|
|
201
|
+
// ⚠️ Only a tool call nobody offered: see the header for who raises which. Every other AiError
|
|
202
|
+
// (a dropped connection, an empty or truncated body, a rate limit) still fails the run.
|
|
203
|
+
Effect.catchTag('AiError', (error) =>
|
|
204
|
+
isToolRefusal(error) ? refused() : Effect.fail(error),
|
|
205
|
+
),
|
|
206
|
+
);
|
|
207
|
+
return forced === undefined
|
|
208
|
+
? { response, rounds: maxRounds, capped: true, unanswered: true }
|
|
209
|
+
: { response: forced, rounds: forcedRound, capped: true, unanswered: false };
|
|
210
|
+
});
|
|
211
|
+
}
|