melchizedek-agents 1.13.0 → 1.15.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/.env.example +13 -2
- package/CHANGELOG.md +92 -0
- package/DOCUMENTATION.md +23 -1
- package/config/agents/syndicate.schema.json +66 -44
- package/config/agents/syndicateSchema.yaml +11 -6
- package/dist/lib/a2a/app.d.ts +13 -1
- package/dist/lib/a2a/app.js +7 -3
- package/dist/lib/a2a/executor.d.ts +14 -4
- package/dist/lib/a2a/executor.js +61 -8
- package/dist/lib/compile.d.ts +4 -4
- package/dist/lib/compileNative.js +4 -1
- package/dist/lib/envSyndicate.d.ts +35 -0
- package/dist/lib/envSyndicate.js +70 -0
- package/dist/lib/index.d.ts +2 -0
- package/dist/lib/index.js +2 -0
- package/dist/lib/loadSyndicate.d.ts +19 -7
- package/dist/lib/loadSyndicate.js +14 -5
- package/dist/lib/models/attachments.d.ts +42 -0
- package/dist/lib/models/attachments.js +80 -0
- package/dist/lib/models/capabilities.d.ts +2 -0
- package/dist/lib/models/capabilities.js +2 -0
- package/dist/lib/models/chatCompletionsAdapter.js +19 -1
- package/dist/lib/models/claudeAdapter.js +25 -0
- package/dist/lib/models/contextWindow.d.ts +32 -0
- package/dist/lib/models/contextWindow.js +73 -0
- package/dist/lib/models/contract.d.ts +15 -1
- package/dist/lib/models/errorResponse.d.ts +4 -0
- package/dist/lib/models/errorResponse.js +25 -0
- package/dist/lib/models/geminiAdapter.js +9 -0
- package/dist/lib/models/genaiMapping.js +10 -1
- package/dist/lib/models/gptAdapter.d.ts +1 -1
- package/dist/lib/models/gptAdapter.js +3 -0
- package/dist/lib/models/jevAdapter.js +2 -0
- package/dist/lib/observability/tracer.js +13 -2
- package/dist/lib/registry.d.ts +23 -9
- package/dist/lib/registry.js +57 -3
- package/dist/lib/runtime/native/agentLoop.js +25 -3
- package/dist/lib/runtime/native/compaction.d.ts +91 -42
- package/dist/lib/runtime/native/compaction.js +188 -86
- package/dist/lib/runtime/native/history.d.ts +52 -1
- package/dist/lib/runtime/native/history.js +80 -6
- package/dist/lib/runtime/native/recall.d.ts +9 -0
- package/dist/lib/runtime/native/recall.js +42 -0
- package/dist/lib/runtime/native/request.d.ts +8 -2
- package/dist/lib/runtime/native/request.js +30 -9
- package/dist/lib/storage/postgres/index.d.ts +3 -0
- package/dist/lib/storage/postgres/index.js +2 -0
- package/dist/lib/syndicateSchema.d.ts +16 -12
- package/dist/lib/syndicateSchema.js +10 -12
- package/dist/lib/tools/memoryTools.js +3 -0
- package/dist/lib/tools/tool.d.ts +9 -2
- package/dist/lib/tools/toolContract.d.ts +1 -1
- package/dist/lib/tools/toolContract.js +4 -2
- package/dist/lib/tools/webExtractTool.js +4 -0
- package/dist/scripts/a2a_server.js +33 -6
- package/dist/scripts/registry.d.ts +7 -4
- package/dist/scripts/registry.js +17 -5
- package/package.json +2 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/envSyndicate.ts — a syndicate handed to the server in the environment.
|
|
3
|
+
*
|
|
4
|
+
* A container started from the published server image has no agents
|
|
5
|
+
* directory of its own and, without a database, no registry to load from.
|
|
6
|
+
* `MELCHIZEDEK_AGENT_YAML` carries the whole syndicate file, base64-encoded
|
|
7
|
+
* (`base64 < agent.yaml`), so a host that only takes environment variables
|
|
8
|
+
* can still run one. `MELCHIZEDEK_AGENT_ID` names it (default `agent`): the
|
|
9
|
+
* id callers address at `/<id>/` and list in `A2A_SERVED_AGENTS`.
|
|
10
|
+
*
|
|
11
|
+
* The file is written into a fresh private directory (0700, the file 0600)
|
|
12
|
+
* that becomes the agents directory, then loaded once, so a bad definition
|
|
13
|
+
* stops the boot instead of failing the first request. Nested
|
|
14
|
+
* `yaml_reference`s have no files to load from here: each must be carried in
|
|
15
|
+
* the definition's own `bundled_references`, as a registry
|
|
16
|
+
* version carries them, or the boot names the missing ones.
|
|
17
|
+
*
|
|
18
|
+
* Imports only node built-ins and lib/loadSyndicate.ts.
|
|
19
|
+
*/
|
|
20
|
+
/** The id a syndicate from the environment is served under unless MELCHIZEDEK_AGENT_ID says otherwise. */
|
|
21
|
+
export declare const ENV_AGENT_ID = "agent";
|
|
22
|
+
export interface EnvSyndicate {
|
|
23
|
+
/** The private directory the file was written to; the server's agents directory. */
|
|
24
|
+
agentsDir: string;
|
|
25
|
+
/** The served id. */
|
|
26
|
+
id: string;
|
|
27
|
+
/** `<id>.yaml`, the default syndicate's file name. */
|
|
28
|
+
file: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The syndicate in `MELCHIZEDEK_AGENT_YAML`, written to disk and checked, or
|
|
32
|
+
* undefined when the variable is unset. Throws naming the variable and, for
|
|
33
|
+
* a definition that does not load, the loader's own reason.
|
|
34
|
+
*/
|
|
35
|
+
export declare function envSyndicate(env?: NodeJS.ProcessEnv, parent?: string): EnvSyndicate | undefined;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/envSyndicate.ts — a syndicate handed to the server in the environment.
|
|
3
|
+
*
|
|
4
|
+
* A container started from the published server image has no agents
|
|
5
|
+
* directory of its own and, without a database, no registry to load from.
|
|
6
|
+
* `MELCHIZEDEK_AGENT_YAML` carries the whole syndicate file, base64-encoded
|
|
7
|
+
* (`base64 < agent.yaml`), so a host that only takes environment variables
|
|
8
|
+
* can still run one. `MELCHIZEDEK_AGENT_ID` names it (default `agent`): the
|
|
9
|
+
* id callers address at `/<id>/` and list in `A2A_SERVED_AGENTS`.
|
|
10
|
+
*
|
|
11
|
+
* The file is written into a fresh private directory (0700, the file 0600)
|
|
12
|
+
* that becomes the agents directory, then loaded once, so a bad definition
|
|
13
|
+
* stops the boot instead of failing the first request. Nested
|
|
14
|
+
* `yaml_reference`s have no files to load from here: each must be carried in
|
|
15
|
+
* the definition's own `bundled_references`, as a registry
|
|
16
|
+
* version carries them, or the boot names the missing ones.
|
|
17
|
+
*
|
|
18
|
+
* Imports only node built-ins and lib/loadSyndicate.ts.
|
|
19
|
+
*/
|
|
20
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { loadSyndicate } from './loadSyndicate.js';
|
|
24
|
+
/** The id a syndicate from the environment is served under unless MELCHIZEDEK_AGENT_ID says otherwise. */
|
|
25
|
+
export const ENV_AGENT_ID = 'agent';
|
|
26
|
+
/** Same charset as an A2A agent id (lib/a2a/app.ts isValidAgentId), without `:` and `.`, which name registry rows and files. */
|
|
27
|
+
const AGENT_ID = /^[A-Za-z0-9_-]{1,120}$/;
|
|
28
|
+
const BASE64 = /^[A-Za-z0-9+/_-]+={0,2}$/;
|
|
29
|
+
/**
|
|
30
|
+
* The syndicate in `MELCHIZEDEK_AGENT_YAML`, written to disk and checked, or
|
|
31
|
+
* undefined when the variable is unset. Throws naming the variable and, for
|
|
32
|
+
* a definition that does not load, the loader's own reason.
|
|
33
|
+
*/
|
|
34
|
+
export function envSyndicate(env = process.env, parent = tmpdir()) {
|
|
35
|
+
const encoded = env.MELCHIZEDEK_AGENT_YAML?.replace(/\s+/g, '');
|
|
36
|
+
if (!encoded)
|
|
37
|
+
return undefined;
|
|
38
|
+
const id = env.MELCHIZEDEK_AGENT_ID?.trim() || ENV_AGENT_ID;
|
|
39
|
+
if (!AGENT_ID.test(id)) {
|
|
40
|
+
throw new Error('MELCHIZEDEK_AGENT_ID must be 1-120 letters, digits, _ or -.');
|
|
41
|
+
}
|
|
42
|
+
if (env.MELCHIZEDEK_AGENTS_DIR?.trim()) {
|
|
43
|
+
throw new Error('MELCHIZEDEK_AGENT_YAML and MELCHIZEDEK_AGENTS_DIR are both set: the syndicate comes from one place. Unset one of them.');
|
|
44
|
+
}
|
|
45
|
+
// Node's base64 decoder takes the URL-safe alphabet too.
|
|
46
|
+
const bytes = BASE64.test(encoded) ? Buffer.from(encoded, 'base64') : undefined;
|
|
47
|
+
if (!bytes?.length) {
|
|
48
|
+
throw new Error('MELCHIZEDEK_AGENT_YAML must be the syndicate file base64-encoded (base64 < agent.yaml), not the YAML itself.');
|
|
49
|
+
}
|
|
50
|
+
const agentsDir = mkdtempSync(join(parent, 'melchizedek-agent-'));
|
|
51
|
+
const file = `${id}.yaml`;
|
|
52
|
+
writeFileSync(join(agentsDir, file), bytes, { mode: 0o600 });
|
|
53
|
+
let config;
|
|
54
|
+
try {
|
|
55
|
+
config = loadSyndicate(file, { agentsDir, shippedFallback: false });
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
59
|
+
throw new Error(`MELCHIZEDEK_AGENT_YAML does not load: ${message}`);
|
|
60
|
+
}
|
|
61
|
+
const bundled = config.bundled_references ?? {};
|
|
62
|
+
const missing = (config.subagents ?? [])
|
|
63
|
+
.map((s) => s.yaml_reference)
|
|
64
|
+
.filter((ref) => typeof ref === 'string' && !Object.hasOwn(bundled, ref));
|
|
65
|
+
if (missing.length) {
|
|
66
|
+
throw new Error(`MELCHIZEDEK_AGENT_YAML references nested syndicates it does not carry: ${missing.join(', ')}. `
|
|
67
|
+
+ 'Put each in the definition\'s bundled_references, or serve from the registry (A2A_REGISTRY_AGENTS).');
|
|
68
|
+
}
|
|
69
|
+
return { agentsDir, id, file };
|
|
70
|
+
}
|
package/dist/lib/index.d.ts
CHANGED
|
@@ -68,6 +68,8 @@ export type { MemoryStore } from './memory/store.ts';
|
|
|
68
68
|
export { validateSyndicateConfig, syndicateJsonSchema, SyndicateValidationError } from './syndicateSchema.ts';
|
|
69
69
|
export { PROVIDERS, providerForModel, providerKeyPresent, providerStatuses, logProviderStatuses, resolveModel, describeCapabilities, capabilitySummary, planTransport, gatewayConfig, gatewayProblem, gatewayUsable, GATEWAYS, } from './models/registry.ts';
|
|
70
70
|
export { loadEnv } from './loadEnv.ts';
|
|
71
|
+
export { attachmentSupport, takesBlob } from './models/attachments.ts';
|
|
72
|
+
export type { AttachmentSupport } from './models/attachments.ts';
|
|
71
73
|
export { setCredentialSource, credentialValue } from './models/credentialSource.ts';
|
|
72
74
|
export type { CredentialSource } from './models/credentialSource.ts';
|
|
73
75
|
export { PROJECT_MANIFEST, projectManifestSchema, ProjectManifestError, findProject, findProjectRoot, loadProject, parseProjectManifest, resolveAgentsDir, listProjectSyndicates, checkProject, type Project, type ProjectManifest, type ProjectCheck, } from './project.ts';
|
package/dist/lib/index.js
CHANGED
|
@@ -54,6 +54,8 @@ export { PROVIDERS, providerForModel, providerKeyPresent, providerStatuses, logP
|
|
|
54
54
|
// subpath until 0.16.0.
|
|
55
55
|
describeCapabilities, capabilitySummary, planTransport, gatewayConfig, gatewayProblem, gatewayUsable, GATEWAYS, } from './models/registry.js';
|
|
56
56
|
export { loadEnv } from './loadEnv.js';
|
|
57
|
+
// Which files a model's path takes in a user message, and how large.
|
|
58
|
+
export { attachmentSupport, takesBlob } from './models/attachments.js';
|
|
57
59
|
// Where credentials are read, by environment variable name (ADR 0133).
|
|
58
60
|
export { setCredentialSource, credentialValue } from './models/credentialSource.js';
|
|
59
61
|
// Project manifest, melchizedek.json (ADR 0138).
|
|
@@ -5,7 +5,7 @@ import type { OpenApiConfig } from './tools/openapiTools.ts';
|
|
|
5
5
|
import type { McpAuthConfig } from './tools/oauthTools.ts';
|
|
6
6
|
import type { McpTransportKind } from './tools/mcpTransports.ts';
|
|
7
7
|
import type { McpServerConfig } from './tools/mcpToolFactory.ts';
|
|
8
|
-
import type {
|
|
8
|
+
import type { ContextSetting, ExampleConfig } from './compile.ts';
|
|
9
9
|
export type { OpenApiConfig } from './tools/openapiTools.ts';
|
|
10
10
|
export type { SkillsConfig } from './tools/skillToolset.ts';
|
|
11
11
|
import type { WorkflowConfig } from './workflowConfig.ts';
|
|
@@ -182,8 +182,8 @@ export interface AgentYamlConfig {
|
|
|
182
182
|
run_code?: {
|
|
183
183
|
attach?: boolean;
|
|
184
184
|
};
|
|
185
|
-
/**
|
|
186
|
-
context?:
|
|
185
|
+
/** What the model is sent of a long conversation: masking and compaction, both on by default (ADR 0033, ADR 0152, ADR 0154), or `off`. */
|
|
186
|
+
context?: ContextSetting;
|
|
187
187
|
/** `task`: the agent works until it calls finish_task; its arguments are the node's output. Workflow nodes only. */
|
|
188
188
|
mode?: 'task';
|
|
189
189
|
/** Few-shot exchanges added to every request's instruction (ADK's ExampleTool). */
|
|
@@ -415,8 +415,20 @@ export declare function nestedLoader(config: SyndicateYamlConfig, fromFiles?: (r
|
|
|
415
415
|
*/
|
|
416
416
|
export declare function validateRegistryConfig(config: Record<string, any>): void;
|
|
417
417
|
/**
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
418
|
+
* Where registry rows are read from over a direct database connection
|
|
419
|
+
* (DATABASE_URL): `postgresRegistryReader(pool)` in lib/registry.ts, which
|
|
420
|
+
* postgresStorage supplies as `registry`.
|
|
421
421
|
*/
|
|
422
|
-
export
|
|
422
|
+
export interface RegistryReader {
|
|
423
|
+
/** The active definition of `id`, or with `version` that stored version; undefined when there is none. */
|
|
424
|
+
definition(id: string, version?: number): Promise<unknown>;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Loads a syndicate configuration from the registry: through `registry`
|
|
428
|
+
* (a direct Postgres connection) when given, else over the Supabase API.
|
|
429
|
+
* @param registryId The document ID in the adk_agent_registry table; `<id>@<version>` loads one stored version
|
|
430
|
+
* @param options Load options (bindings and overrides), and the reader
|
|
431
|
+
*/
|
|
432
|
+
export declare function loadSyndicateFromRegistry(registryId: string, options?: LoadSyndicateOptions & {
|
|
433
|
+
registry?: RegistryReader;
|
|
434
|
+
}): Promise<SyndicateYamlConfig>;
|
|
@@ -400,13 +400,22 @@ export function validateRegistryConfig(config) {
|
|
|
400
400
|
}
|
|
401
401
|
}
|
|
402
402
|
/**
|
|
403
|
-
* Loads a syndicate configuration from the
|
|
404
|
-
*
|
|
405
|
-
* @param
|
|
403
|
+
* Loads a syndicate configuration from the registry: through `registry`
|
|
404
|
+
* (a direct Postgres connection) when given, else over the Supabase API.
|
|
405
|
+
* @param registryId The document ID in the adk_agent_registry table; `<id>@<version>` loads one stored version
|
|
406
|
+
* @param options Load options (bindings and overrides), and the reader
|
|
406
407
|
*/
|
|
407
408
|
export async function loadSyndicateFromRegistry(registryId, options = {}) {
|
|
409
|
+
const { registry, ...loadOptions } = options;
|
|
410
|
+
if (registry) {
|
|
411
|
+
const pinnedRef = /^(.+)@(\d+)$/.exec(registryId);
|
|
412
|
+
const definition = pinnedRef ? await registry.definition(pinnedRef[1], Number(pinnedRef[2])) : await registry.definition(registryId);
|
|
413
|
+
if (definition === undefined || definition === null)
|
|
414
|
+
throw new Error(`Registry document ${registryId} not found`);
|
|
415
|
+
return resolveAndValidate(definition, `registry:${registryId}`, loadOptions);
|
|
416
|
+
}
|
|
408
417
|
if (!hasSupabaseCredentials()) {
|
|
409
|
-
throw new Error('
|
|
418
|
+
throw new Error('No registry to load from: set DATABASE_URL (a direct Postgres connection), or SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY.');
|
|
410
419
|
}
|
|
411
420
|
const supabaseUrl = process.env.SUPABASE_URL;
|
|
412
421
|
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
@@ -427,5 +436,5 @@ export async function loadSyndicateFromRegistry(registryId, options = {}) {
|
|
|
427
436
|
}
|
|
428
437
|
// The registry row is the live copy of a file (scripts/deploy_agent.ts), so
|
|
429
438
|
// it answers to the same schema; the label says where the bad copy lives.
|
|
430
|
-
return resolveAndValidate(data.yaml_content, `registry:${registryId}`,
|
|
439
|
+
return resolveAndValidate(data.yaml_content, `registry:${registryId}`, loadOptions);
|
|
431
440
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/models/attachments.ts — which files a model's path takes in a user
|
|
3
|
+
* message, and how large.
|
|
4
|
+
*
|
|
5
|
+
* WHY this file exists:
|
|
6
|
+
* A file reaches a model as a blob part, and what an adapter can send
|
|
7
|
+
* differs by provider: Gemini takes audio and video, the Messages API and
|
|
8
|
+
* the Responses API take PDFs, a chat-completions path takes images only. A
|
|
9
|
+
* caller that converts or shrinks a file first (the desktop app), and the
|
|
10
|
+
* A2A server that refuses one, read the same table, so neither learns of a
|
|
11
|
+
* mismatch from a provider's 400.
|
|
12
|
+
*
|
|
13
|
+
* Limits are the provider's published ones, set a little under where the
|
|
14
|
+
* base64 a request carries is what is counted. `maxRequestBytes` is decoded
|
|
15
|
+
* bytes across a whole request, and a conversation's earlier files are in it.
|
|
16
|
+
*/
|
|
17
|
+
export interface AttachmentSupport {
|
|
18
|
+
/** Image types sent as images. */
|
|
19
|
+
images: readonly string[];
|
|
20
|
+
/** Document types sent as documents; anything else must arrive as text. */
|
|
21
|
+
documents: readonly string[];
|
|
22
|
+
/** Audio and video types sent as they are. */
|
|
23
|
+
media: readonly string[];
|
|
24
|
+
/** The longest image edge worth sending, in pixels: past it the provider shrinks the image or refuses it. */
|
|
25
|
+
maxImageEdge: number;
|
|
26
|
+
/** Largest image, decoded bytes. */
|
|
27
|
+
maxImageBytes: number;
|
|
28
|
+
/** Largest document or media file, decoded bytes. */
|
|
29
|
+
maxFileBytes: number;
|
|
30
|
+
/** Largest total of files in one request, decoded bytes. */
|
|
31
|
+
maxRequestBytes: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* What `model` takes on the path it will actually run on. A gateway speaks
|
|
35
|
+
* chat completions whichever provider is behind it, so a direct-only type is
|
|
36
|
+
* not offered there. An id no path serves reads as the plain table.
|
|
37
|
+
*/
|
|
38
|
+
export declare function attachmentSupport(model: unknown, opts?: {
|
|
39
|
+
callerKey?: boolean;
|
|
40
|
+
}): AttachmentSupport;
|
|
41
|
+
/** Whether a media type is sent as a blob on this path (not whether it fits). */
|
|
42
|
+
export declare function takesBlob(support: AttachmentSupport, mediaType: string): boolean;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/models/attachments.ts — which files a model's path takes in a user
|
|
3
|
+
* message, and how large.
|
|
4
|
+
*
|
|
5
|
+
* WHY this file exists:
|
|
6
|
+
* A file reaches a model as a blob part, and what an adapter can send
|
|
7
|
+
* differs by provider: Gemini takes audio and video, the Messages API and
|
|
8
|
+
* the Responses API take PDFs, a chat-completions path takes images only. A
|
|
9
|
+
* caller that converts or shrinks a file first (the desktop app), and the
|
|
10
|
+
* A2A server that refuses one, read the same table, so neither learns of a
|
|
11
|
+
* mismatch from a provider's 400.
|
|
12
|
+
*
|
|
13
|
+
* Limits are the provider's published ones, set a little under where the
|
|
14
|
+
* base64 a request carries is what is counted. `maxRequestBytes` is decoded
|
|
15
|
+
* bytes across a whole request, and a conversation's earlier files are in it.
|
|
16
|
+
*/
|
|
17
|
+
import { planTransport } from './gateway.js';
|
|
18
|
+
const MB = 1024 * 1024;
|
|
19
|
+
/** Any path that takes a data URI: the chat-completions adapters, Ollama and the gateways. */
|
|
20
|
+
const PLAIN = {
|
|
21
|
+
images: ['image/jpeg', 'image/png'],
|
|
22
|
+
documents: [],
|
|
23
|
+
media: [],
|
|
24
|
+
maxImageEdge: 1568,
|
|
25
|
+
maxImageBytes: 8 * MB,
|
|
26
|
+
maxFileBytes: 0,
|
|
27
|
+
maxRequestBytes: 14 * MB,
|
|
28
|
+
};
|
|
29
|
+
const DIRECT = {
|
|
30
|
+
anthropic: {
|
|
31
|
+
images: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
|
32
|
+
documents: ['application/pdf'],
|
|
33
|
+
media: [],
|
|
34
|
+
maxImageEdge: 1568,
|
|
35
|
+
maxImageBytes: 3.75 * MB,
|
|
36
|
+
maxFileBytes: 22 * MB,
|
|
37
|
+
maxRequestBytes: 22 * MB,
|
|
38
|
+
},
|
|
39
|
+
openai: {
|
|
40
|
+
images: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
|
41
|
+
documents: ['application/pdf'],
|
|
42
|
+
media: [],
|
|
43
|
+
maxImageEdge: 2048,
|
|
44
|
+
maxImageBytes: 20 * MB,
|
|
45
|
+
maxFileBytes: 32 * MB,
|
|
46
|
+
maxRequestBytes: 36 * MB,
|
|
47
|
+
},
|
|
48
|
+
gemini: {
|
|
49
|
+
images: ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'],
|
|
50
|
+
documents: ['application/pdf'],
|
|
51
|
+
media: [
|
|
52
|
+
'audio/wav', 'audio/mp3', 'audio/mpeg', 'audio/aiff', 'audio/aac', 'audio/ogg', 'audio/flac',
|
|
53
|
+
'video/mp4', 'video/mpeg', 'video/quicktime', 'video/webm',
|
|
54
|
+
],
|
|
55
|
+
maxImageEdge: 3072,
|
|
56
|
+
maxImageBytes: 14 * MB,
|
|
57
|
+
maxFileBytes: 14 * MB,
|
|
58
|
+
maxRequestBytes: 14 * MB,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* What `model` takes on the path it will actually run on. A gateway speaks
|
|
63
|
+
* chat completions whichever provider is behind it, so a direct-only type is
|
|
64
|
+
* not offered there. An id no path serves reads as the plain table.
|
|
65
|
+
*/
|
|
66
|
+
export function attachmentSupport(model, opts = {}) {
|
|
67
|
+
if (typeof model !== 'string' || !model)
|
|
68
|
+
return PLAIN;
|
|
69
|
+
try {
|
|
70
|
+
const plan = planTransport(model, opts);
|
|
71
|
+
return (plan.transport === 'direct' && DIRECT[plan.provider]) || PLAIN;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return PLAIN;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Whether a media type is sent as a blob on this path (not whether it fits). */
|
|
78
|
+
export function takesBlob(support, mediaType) {
|
|
79
|
+
return support.images.includes(mediaType) || support.documents.includes(mediaType) || support.media.includes(mediaType);
|
|
80
|
+
}
|
|
@@ -48,6 +48,8 @@ export interface CapabilityReport {
|
|
|
48
48
|
dropped: string[];
|
|
49
49
|
/** Declared client-side tools; portable across every path. */
|
|
50
50
|
portable: string[];
|
|
51
|
+
/** The tokens the model's context holds (lib/models/contextWindow.ts); absent for a provider the table does not know. */
|
|
52
|
+
contextWindow?: number;
|
|
51
53
|
}
|
|
52
54
|
export declare function describeCapabilities(model: string, tools?: readonly string[], opts?: {
|
|
53
55
|
callerKey?: boolean;
|
|
@@ -21,6 +21,7 @@ import { PROVIDERS } from './providerMap.js';
|
|
|
21
21
|
import { gatewaySearch, planTransport } from './gateway.js';
|
|
22
22
|
import { nativeSearchOn, PLATFORMS_FOR, platformFromEnv } from './endpoints.js';
|
|
23
23
|
import { claudeGeneration, claudeUrlImagesOn } from './claudeModels.js';
|
|
24
|
+
import { contextWindowOf } from './contextWindow.js';
|
|
24
25
|
/** The platform a direct path uses (ADR 0023); a misconfigured one reads as direct here, the doctor reports it. */
|
|
25
26
|
function directPlatform(provider) {
|
|
26
27
|
try {
|
|
@@ -79,6 +80,7 @@ export function describeCapabilities(model, tools = [], opts = {}) {
|
|
|
79
80
|
native,
|
|
80
81
|
dropped,
|
|
81
82
|
portable,
|
|
83
|
+
...(contextWindowOf(model) !== undefined ? { contextWindow: contextWindowOf(model) } : {}),
|
|
82
84
|
};
|
|
83
85
|
}
|
|
84
86
|
/**
|
|
@@ -837,7 +837,12 @@ export class ChatCompletionsAdapter {
|
|
|
837
837
|
else if (part.data) {
|
|
838
838
|
// Inline only: a data URI for vision models. A URL blob is not
|
|
839
839
|
// sent (Moonshot takes no public image URLs).
|
|
840
|
-
|
|
840
|
+
const type = !part.mimeType || part.mimeType === 'application/octet-stream' ? 'image/png' : part.mimeType;
|
|
841
|
+
// This wire has images only; a PDF labelled as one would fail the whole request.
|
|
842
|
+
if (type.startsWith('image/'))
|
|
843
|
+
images.push({ type: 'image_url', image_url: { url: `data:${type};base64,${part.data}` } });
|
|
844
|
+
else
|
|
845
|
+
console.warn(`⚠ A file part (${type.replace(/[^\x20-\x7e]/g, '?').slice(0, 64)}) is not sent: this path takes images only.`);
|
|
841
846
|
}
|
|
842
847
|
}
|
|
843
848
|
const text = texts.join('\n');
|
|
@@ -891,6 +896,19 @@ export class ChatCompletionsAdapter {
|
|
|
891
896
|
return;
|
|
892
897
|
}
|
|
893
898
|
});
|
|
899
|
+
// This step's notes, after the history: joined to a last user message's
|
|
900
|
+
// text, or a user message of their own after tool results, so the cached
|
|
901
|
+
// prefix before them holds. Never a trailing system message: some
|
|
902
|
+
// OpenAI-compatible servers take system first only.
|
|
903
|
+
if (request.turnNotes) {
|
|
904
|
+
const last = messages[messages.length - 1];
|
|
905
|
+
if (last?.role === 'user' && typeof last.content === 'string')
|
|
906
|
+
last.content = `${last.content}\n\n${request.turnNotes}`;
|
|
907
|
+
else if (last?.role === 'user' && Array.isArray(last.content))
|
|
908
|
+
last.content.push({ type: 'text', text: request.turnNotes });
|
|
909
|
+
else
|
|
910
|
+
messages.push({ role: 'user', content: request.turnNotes });
|
|
911
|
+
}
|
|
894
912
|
if (system.length > 0)
|
|
895
913
|
messages.unshift({ role: 'system', content: system.join('\n\n') });
|
|
896
914
|
return messages;
|
|
@@ -105,6 +105,16 @@ function imageBlockFor(part, urlImages) {
|
|
|
105
105
|
const named = (type) => type.replace(/[^\x20-\x7e]/g, '?').slice(0, 64);
|
|
106
106
|
const declared = String(part.mimeType ?? '').toLowerCase();
|
|
107
107
|
const untyped = UNTYPED.has(declared);
|
|
108
|
+
if (declared === 'application/pdf') {
|
|
109
|
+
if (part.data !== undefined)
|
|
110
|
+
return part.data ? { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: part.data } } : undefined;
|
|
111
|
+
const uri = String(part.url ?? '');
|
|
112
|
+
if (!uri)
|
|
113
|
+
return undefined;
|
|
114
|
+
if (!/^https:\/\//i.test(uri))
|
|
115
|
+
return `non-https URL (${/^[a-z][a-z0-9+.-]*:/i.exec(uri)?.[0] ?? 'no scheme'})`;
|
|
116
|
+
return urlImages ? { type: 'document', source: { type: 'url', url: uri } } : URL_SOURCE;
|
|
117
|
+
}
|
|
108
118
|
if (part.data !== undefined) {
|
|
109
119
|
if (!part.data)
|
|
110
120
|
return undefined;
|
|
@@ -341,6 +351,15 @@ export class ClaudeAdapter {
|
|
|
341
351
|
if (blocks.length > 0)
|
|
342
352
|
messages.push({ role, content: blocks });
|
|
343
353
|
});
|
|
354
|
+
// This step's notes, after the history: on the last user message (tool
|
|
355
|
+
// results are user content here too), so the cached prefix before it holds.
|
|
356
|
+
if (request.turnNotes) {
|
|
357
|
+
const last = messages[messages.length - 1];
|
|
358
|
+
if (last?.role === 'user')
|
|
359
|
+
last.content.push({ type: 'text', text: request.turnNotes });
|
|
360
|
+
else
|
|
361
|
+
messages.push({ role: 'user', content: [{ type: 'text', text: request.turnNotes }] });
|
|
362
|
+
}
|
|
344
363
|
if (droppedImages.size > 0) {
|
|
345
364
|
setLlmSpanAttribute('llm.image.dropped', [...droppedImages].join(','));
|
|
346
365
|
for (const what of droppedImages) {
|
|
@@ -474,6 +493,12 @@ export class ClaudeAdapter {
|
|
|
474
493
|
...(thinking ? { thinking } : {}),
|
|
475
494
|
...(Object.keys(outputConfig).length > 0 ? { output_config: outputConfig } : {}),
|
|
476
495
|
...(plan?.dropBlock ? { betas: [THINKING_BINDING_BETA] } : {}),
|
|
496
|
+
// Automatic prompt caching: the API puts the breakpoint on the last
|
|
497
|
+
// cacheable block and moves it forward as the history grows, so each
|
|
498
|
+
// step reads the previous step's prefix at the cached price. The loop
|
|
499
|
+
// keeps the prefix stable for it (ModelRequest.turnNotes). Anthropic's
|
|
500
|
+
// own API only until Bedrock and Vertex are confirmed to take the field.
|
|
501
|
+
...(endpoint.platform === 'direct' ? { cache_control: { type: 'ephemeral' } } : {}),
|
|
477
502
|
};
|
|
478
503
|
return { body, beta: plan?.dropBlock === true, structuredTool: structuredTool !== undefined };
|
|
479
504
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/models/contextWindow.ts — how many tokens a model's context holds,
|
|
3
|
+
* read from the model id (ADR 0154).
|
|
4
|
+
*
|
|
5
|
+
* WHY this file exists:
|
|
6
|
+
* Compaction on by default needs a threshold per model, and nothing in the
|
|
7
|
+
* registry said how big a window is. The numbers here are each provider's
|
|
8
|
+
* published context length for the id families the engine routes
|
|
9
|
+
* (lib/models/providerMap.ts), newest row first; an id a family does not
|
|
10
|
+
* name gets the family's newest row, as the Claude generation table does.
|
|
11
|
+
* Ollama's is whatever the server was started with: OLLAMA_CONTEXT_LENGTH
|
|
12
|
+
* when set, else Ollama's 4,096 default (lib/models/ollamaAdapter.ts), the
|
|
13
|
+
* one provider that cannot report an overflow, which is why its threshold
|
|
14
|
+
* must be right.
|
|
15
|
+
*
|
|
16
|
+
* `compactAfterTokens(model)` is the default `compact_after_tokens`: 60%
|
|
17
|
+
* of the window, so a step's own output, the summary and a tool result
|
|
18
|
+
* or two still fit after the threshold is passed. A window the table does
|
|
19
|
+
* not know gets UNKNOWN_WINDOW.
|
|
20
|
+
*/
|
|
21
|
+
/** Assumed for a provider the table does not know. */
|
|
22
|
+
export declare const UNKNOWN_WINDOW = 100000;
|
|
23
|
+
/** The share of the window past which an agent compacts by default. */
|
|
24
|
+
export declare const COMPACT_AT_SHARE = 0.6;
|
|
25
|
+
/** Ollama's context length unless the server says otherwise. */
|
|
26
|
+
export declare const OLLAMA_DEFAULT_CONTEXT = 4096;
|
|
27
|
+
/** The Ollama server's context length: OLLAMA_CONTEXT_LENGTH, else Ollama's default. */
|
|
28
|
+
export declare function ollamaContextLength(env?: NodeJS.ProcessEnv): number;
|
|
29
|
+
/** The tokens `model`'s context holds, or undefined for a provider the table does not know (Jev, a gateway-only id). */
|
|
30
|
+
export declare function contextWindowOf(model: string, env?: NodeJS.ProcessEnv): number | undefined;
|
|
31
|
+
/** The default `compact_after_tokens` for `model`: COMPACT_AT_SHARE of its window. */
|
|
32
|
+
export declare function compactAfterTokens(model: string, env?: NodeJS.ProcessEnv): number;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/models/contextWindow.ts — how many tokens a model's context holds,
|
|
3
|
+
* read from the model id (ADR 0154).
|
|
4
|
+
*
|
|
5
|
+
* WHY this file exists:
|
|
6
|
+
* Compaction on by default needs a threshold per model, and nothing in the
|
|
7
|
+
* registry said how big a window is. The numbers here are each provider's
|
|
8
|
+
* published context length for the id families the engine routes
|
|
9
|
+
* (lib/models/providerMap.ts), newest row first; an id a family does not
|
|
10
|
+
* name gets the family's newest row, as the Claude generation table does.
|
|
11
|
+
* Ollama's is whatever the server was started with: OLLAMA_CONTEXT_LENGTH
|
|
12
|
+
* when set, else Ollama's 4,096 default (lib/models/ollamaAdapter.ts), the
|
|
13
|
+
* one provider that cannot report an overflow, which is why its threshold
|
|
14
|
+
* must be right.
|
|
15
|
+
*
|
|
16
|
+
* `compactAfterTokens(model)` is the default `compact_after_tokens`: 60%
|
|
17
|
+
* of the window, so a step's own output, the summary and a tool result
|
|
18
|
+
* or two still fit after the threshold is passed. A window the table does
|
|
19
|
+
* not know gets UNKNOWN_WINDOW.
|
|
20
|
+
*/
|
|
21
|
+
import { providerForModel } from './providerMap.js';
|
|
22
|
+
/** Assumed for a provider the table does not know. */
|
|
23
|
+
export const UNKNOWN_WINDOW = 100_000;
|
|
24
|
+
/** The share of the window past which an agent compacts by default. */
|
|
25
|
+
export const COMPACT_AT_SHARE = 0.6;
|
|
26
|
+
/** Ollama's context length unless the server says otherwise. */
|
|
27
|
+
export const OLLAMA_DEFAULT_CONTEXT = 4_096;
|
|
28
|
+
const CLAUDE = [[/^claude-/, 200_000]];
|
|
29
|
+
const OPENAI = [
|
|
30
|
+
[/^gpt-4o/, 128_000],
|
|
31
|
+
[/^gpt-4\.1/, 1_047_576],
|
|
32
|
+
[/^o[0-9]/, 200_000],
|
|
33
|
+
[/^gpt-5/, 400_000],
|
|
34
|
+
];
|
|
35
|
+
const GEMINI = [[/^gemini-/, 1_048_576]];
|
|
36
|
+
const XAI = [
|
|
37
|
+
[/^grok-3/, 131_072],
|
|
38
|
+
[/^grok-4/, 256_000],
|
|
39
|
+
];
|
|
40
|
+
const MOONSHOT = [
|
|
41
|
+
[/^kimi-k2/, 262_144],
|
|
42
|
+
[/^kimi-k3/, 1_000_000],
|
|
43
|
+
];
|
|
44
|
+
const DEEPSEEK = [[/^deepseek-/, 1_000_000]];
|
|
45
|
+
const FAMILIES = {
|
|
46
|
+
anthropic: CLAUDE,
|
|
47
|
+
openai: OPENAI,
|
|
48
|
+
gemini: GEMINI,
|
|
49
|
+
xai: XAI,
|
|
50
|
+
moonshot: MOONSHOT,
|
|
51
|
+
deepseek: DEEPSEEK,
|
|
52
|
+
};
|
|
53
|
+
/** The Ollama server's context length: OLLAMA_CONTEXT_LENGTH, else Ollama's default. */
|
|
54
|
+
export function ollamaContextLength(env = process.env) {
|
|
55
|
+
const set = Number(env.OLLAMA_CONTEXT_LENGTH);
|
|
56
|
+
return Number.isFinite(set) && set > 0 ? Math.floor(set) : OLLAMA_DEFAULT_CONTEXT;
|
|
57
|
+
}
|
|
58
|
+
/** The tokens `model`'s context holds, or undefined for a provider the table does not know (Jev, a gateway-only id). */
|
|
59
|
+
export function contextWindowOf(model, env = process.env) {
|
|
60
|
+
const provider = providerForModel(model);
|
|
61
|
+
if (provider === 'ollama')
|
|
62
|
+
return ollamaContextLength(env);
|
|
63
|
+
const rows = FAMILIES[provider];
|
|
64
|
+
if (!rows || rows.length === 0)
|
|
65
|
+
return undefined;
|
|
66
|
+
const bare = model.replace(/^[a-z-]+\//, '');
|
|
67
|
+
const hit = rows.find(([pattern]) => pattern.test(bare));
|
|
68
|
+
return (hit ?? rows[rows.length - 1])[1];
|
|
69
|
+
}
|
|
70
|
+
/** The default `compact_after_tokens` for `model`: COMPACT_AT_SHARE of its window. */
|
|
71
|
+
export function compactAfterTokens(model, env = process.env) {
|
|
72
|
+
return Math.round((contextWindowOf(model, env) ?? UNKNOWN_WINDOW) * COMPACT_AT_SHARE);
|
|
73
|
+
}
|
|
@@ -207,10 +207,24 @@ export interface ModelRequest {
|
|
|
207
207
|
* request on.
|
|
208
208
|
*/
|
|
209
209
|
model: string;
|
|
210
|
-
/**
|
|
210
|
+
/**
|
|
211
|
+
* The system prompt. Only text that is the same from one request of the
|
|
212
|
+
* agent to the next belongs here: every provider caches the prompt as a
|
|
213
|
+
* prefix (tools, then system, then the history), so a word that changes
|
|
214
|
+
* here misses the whole history behind it. Text for this step alone goes
|
|
215
|
+
* in `turnNotes`.
|
|
216
|
+
*/
|
|
211
217
|
system?: string;
|
|
212
218
|
/** The conversation, oldest first. */
|
|
213
219
|
messages: Message[];
|
|
220
|
+
/**
|
|
221
|
+
* Text the model reads this step that is not part of the durable
|
|
222
|
+
* instruction: recalled facts, a note for this turn. Each adapter places
|
|
223
|
+
* it after the history, where it leaves the cached prefix alone: as text
|
|
224
|
+
* at the end of the last user message, or as a user message of its own
|
|
225
|
+
* when the history ends on tool results. Absent means none.
|
|
226
|
+
*/
|
|
227
|
+
turnNotes?: string;
|
|
214
228
|
/** Client-side tools. */
|
|
215
229
|
tools?: ToolDeclaration[];
|
|
216
230
|
/** Provider-side tools, by name. */
|
|
@@ -33,6 +33,10 @@ export declare const ERROR_STATUS_KEY = "error.status";
|
|
|
33
33
|
export declare function errorText(err: unknown): string;
|
|
34
34
|
/** What a caught error says about trying again. A cancellation never is retryable. */
|
|
35
35
|
export declare function errorDecision(err: unknown): RetryDecision;
|
|
36
|
+
export declare function isContextOverflow(error: {
|
|
37
|
+
message?: string;
|
|
38
|
+
status?: number;
|
|
39
|
+
} | undefined): boolean;
|
|
36
40
|
/** What a non-2xx HTTP status says about trying again. */
|
|
37
41
|
export declare function statusDecision(status: number): RetryDecision;
|
|
38
42
|
/**
|
|
@@ -14,6 +14,31 @@ export function errorDecision(err) {
|
|
|
14
14
|
const decision = classifyError(err);
|
|
15
15
|
return err?.name === 'AbortError' ? { ...decision, retryable: false } : decision;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* The provider refused the request because the prompt no longer fits the
|
|
19
|
+
* model's window (ADR 0154): a 400 (or 413, or a status it did not give)
|
|
20
|
+
* whose message says so in one of the providers' spellings. The loop
|
|
21
|
+
* answers it with a forced compaction and one retry; the fallback never
|
|
22
|
+
* redirects it (it is the request's fault, not the provider's).
|
|
23
|
+
*
|
|
24
|
+
* Anthropic "prompt is too long: 213463 tokens > 200000 maximum"
|
|
25
|
+
* OpenAI "This model's maximum context length is 128000 tokens …" /
|
|
26
|
+
* "Your input exceeds the context window of this model" (context_length_exceeded)
|
|
27
|
+
* Gemini "The input token count (…) exceeds the maximum number of tokens allowed (…)"
|
|
28
|
+
* xAI "This model's maximum prompt length is 131072 …"
|
|
29
|
+
* Moonshot "Your request exceeded model token limit: 262144"
|
|
30
|
+
* DeepSeek "This model's maximum context length is 1000000 tokens …"
|
|
31
|
+
* llama.cpp "the request exceeds the available context size"
|
|
32
|
+
* Ollama reports nothing: it truncates the prompt instead.
|
|
33
|
+
*/
|
|
34
|
+
const OVERFLOW_MESSAGE = /prompt is too long|context[_ ]length|context window|maximum (?:prompt|context) length|input token count .*exceeds|exceeds the (?:available )?context|exceeded model token limit|request_too_large|input is too long|too many tokens/i;
|
|
35
|
+
export function isContextOverflow(error) {
|
|
36
|
+
if (!error?.message)
|
|
37
|
+
return false;
|
|
38
|
+
if (error.status !== undefined && error.status !== 400 && error.status !== 413)
|
|
39
|
+
return false;
|
|
40
|
+
return OVERFLOW_MESSAGE.test(error.message);
|
|
41
|
+
}
|
|
17
42
|
/** What a non-2xx HTTP status says about trying again. */
|
|
18
43
|
export function statusDecision(status) {
|
|
19
44
|
return { retryable: isRetryableStatus(status), status };
|
|
@@ -249,6 +249,15 @@ function buildRequest(request, options) {
|
|
|
249
249
|
if (parts.length)
|
|
250
250
|
contents.push({ role: message.role === 'assistant' ? 'model' : 'user', parts });
|
|
251
251
|
});
|
|
252
|
+
// This step's notes, after the history, on the last user content (function
|
|
253
|
+
// responses are user content too), so the cached prefix before it holds.
|
|
254
|
+
if (request.turnNotes) {
|
|
255
|
+
const last = contents[contents.length - 1];
|
|
256
|
+
if (last?.role === 'user')
|
|
257
|
+
last.parts.push({ text: request.turnNotes });
|
|
258
|
+
else
|
|
259
|
+
contents.push({ role: 'user', parts: [{ text: request.turnNotes }] });
|
|
260
|
+
}
|
|
252
261
|
const config = {};
|
|
253
262
|
if (system.length)
|
|
254
263
|
config.systemInstruction = { parts: system.map((text) => ({ text })) };
|