@capekai/core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/package.json +105 -0
- package/src/adapters/ai-sdk.ts +84 -0
- package/src/compaction/contracts.ts +82 -0
- package/src/compaction/executor.ts +161 -0
- package/src/compaction/policy.ts +318 -0
- package/src/compaction/recovery.ts +139 -0
- package/src/compaction/task.ts +540 -0
- package/src/configuration/contracts.ts +58 -0
- package/src/configuration/defaults.ts +27 -0
- package/src/configuration/runtime.ts +42 -0
- package/src/configuration/single-model.ts +75 -0
- package/src/context/assembler.ts +112 -0
- package/src/context/index.ts +2 -0
- package/src/context/sources.ts +119 -0
- package/src/context/workspace.ts +63 -0
- package/src/core/agent.ts +401 -0
- package/src/core/build-tools.ts +139 -0
- package/src/core/chat-handler.ts +858 -0
- package/src/core/error-handling.ts +18 -0
- package/src/core/fork.ts +103 -0
- package/src/core/interrupt.ts +192 -0
- package/src/core/message-utils.ts +261 -0
- package/src/core/model-utils.ts +149 -0
- package/src/core/part-utils.ts +88 -0
- package/src/core/provider-utils.ts +67 -0
- package/src/core/revert.ts +46 -0
- package/src/core/step-handlers.ts +157 -0
- package/src/core/stream/finalization.ts +65 -0
- package/src/core/stream/stream-config.ts +82 -0
- package/src/core/stream-handlers.ts +242 -0
- package/src/core/structured-output.ts +68 -0
- package/src/core/tool-builders/agent-tools.ts +71 -0
- package/src/core/tool-builders/external-tools.ts +179 -0
- package/src/core/tool-builders/types.ts +16 -0
- package/src/core/tool-builders/workspace-tools.ts +293 -0
- package/src/core/tool-capabilities.ts +65 -0
- package/src/goals/evaluator.ts +171 -0
- package/src/goals/index.ts +3 -0
- package/src/goals/loop.ts +167 -0
- package/src/goals/service.ts +39 -0
- package/src/index.ts +10 -0
- package/src/internal/ask-authority.ts +29 -0
- package/src/internal/composition.ts +44 -0
- package/src/internal/configuration.ts +22 -0
- package/src/internal/execution.ts +108 -0
- package/src/internal/hosts.ts +64 -0
- package/src/internal/plugins.ts +71 -0
- package/src/internal/providers.ts +32 -0
- package/src/internal/sandbox.ts +19 -0
- package/src/internal/tools.ts +48 -0
- package/src/internal/workspace.ts +25 -0
- package/src/kernel/diagnostics.ts +249 -0
- package/src/kernel/errors.ts +120 -0
- package/src/kernel/events.ts +82 -0
- package/src/kernel/index.ts +72 -0
- package/src/kernel/kernel.ts +62 -0
- package/src/kernel/lifecycle.ts +72 -0
- package/src/kernel/plugin.ts +218 -0
- package/src/kernel/registry.ts +493 -0
- package/src/kernel/scope.ts +776 -0
- package/src/kernel/service-key.ts +19 -0
- package/src/kernel/types.ts +317 -0
- package/src/memory/index.ts +2 -0
- package/src/memory/memory-tool.ts +75 -0
- package/src/memory/registry.ts +172 -0
- package/src/permission/ask-user-api.ts +70 -0
- package/src/permission/contracts.ts +135 -0
- package/src/permission/permission-request-manager.ts +58 -0
- package/src/permission/policy.ts +277 -0
- package/src/permission/runtime.ts +612 -0
- package/src/plugins/compaction-policy.ts +46 -0
- package/src/plugins/compose.ts +171 -0
- package/src/plugins/context-sections.ts +246 -0
- package/src/plugins/default-agent-driver.ts +14 -0
- package/src/plugins/facade-plugins.ts +129 -0
- package/src/plugins/goal-domain.ts +82 -0
- package/src/plugins/legacy-system-message.ts +152 -0
- package/src/plugins/loaded-tools.ts +23 -0
- package/src/plugins/memory-domain.ts +264 -0
- package/src/plugins/orchestrator-session.ts +29 -0
- package/src/plugins/permission-policy.ts +49 -0
- package/src/plugins/retry-policy.ts +28 -0
- package/src/plugins/scheduler-domain.ts +192 -0
- package/src/plugins/service-keys.ts +294 -0
- package/src/plugins/session-search-domain.ts +238 -0
- package/src/plugins/skills-domain.ts +272 -0
- package/src/plugins/subagent-domain.ts +287 -0
- package/src/plugins/tool-catalog.ts +78 -0
- package/src/plugins/tool-output-policy.ts +52 -0
- package/src/plugins/value-plugins.ts +150 -0
- package/src/plugins/workflow-domain.ts +198 -0
- package/src/plugins/workspace-policy.ts +37 -0
- package/src/providers/registry.ts +63 -0
- package/src/providers/types.ts +44 -0
- package/src/retry/policy.ts +282 -0
- package/src/retry/stream-chat.ts +312 -0
- package/src/runtime/agent-runtime.ts +83 -0
- package/src/runtime/default-agent-driver.ts +23 -0
- package/src/runtime/domain-tool-source.ts +156 -0
- package/src/runtime/events.ts +61 -0
- package/src/runtime/host-dependencies.ts +71 -0
- package/src/runtime/host-guidance.ts +22 -0
- package/src/runtime/host-layout.ts +23 -0
- package/src/runtime/host.ts +129 -0
- package/src/runtime/standalone-host.ts +118 -0
- package/src/sandbox/controller.ts +204 -0
- package/src/sandbox/model.ts +305 -0
- package/src/sandbox/provider.ts +53 -0
- package/src/sandbox/types.ts +110 -0
- package/src/scheduler/host.ts +22 -0
- package/src/scheduler/scheduler-tool.ts +172 -0
- package/src/session-search/host.ts +56 -0
- package/src/session-search/index.ts +23 -0
- package/src/session-search/session-search-tool.ts +151 -0
- package/src/skills/index.ts +3 -0
- package/src/skills/registry.ts +63 -0
- package/src/skills/skill-manage-tool.ts +205 -0
- package/src/skills/skill-tool.ts +42 -0
- package/src/storage/contracts.ts +159 -0
- package/src/storage/memory.ts +321 -0
- package/src/storage/options.ts +75 -0
- package/src/storage/runtime.ts +115 -0
- package/src/storage/sqlite-tool-output-artifacts.ts +106 -0
- package/src/storage/sqlite.ts +321 -0
- package/src/storage/tool-output-artifacts.ts +75 -0
- package/src/storage.ts +31 -0
- package/src/subagent/child-session.ts +282 -0
- package/src/subagent/guidance.ts +8 -0
- package/src/subagent/policy.ts +198 -0
- package/src/subagent/task-tool.ts +584 -0
- package/src/tool-output/contracts.ts +111 -0
- package/src/tool-output/policy.ts +410 -0
- package/src/tool.ts +1 -0
- package/src/tools/executor.ts +258 -0
- package/src/tools/install-manifest.ts +40 -0
- package/src/tools/llm-api.ts +77 -0
- package/src/tools/registry.ts +206 -0
- package/src/tools/tool-artifact.ts +182 -0
- package/src/tools/tool-source.ts +53 -0
- package/src/utils/errors.ts +334 -0
- package/src/utils/strip-visualization.ts +50 -0
- package/src/workflow/decomposer.ts +139 -0
- package/src/workflow/execution.ts +523 -0
- package/src/workflow/orchestrator-session.ts +161 -0
- package/src/workflow/synthesizer.ts +130 -0
- package/src/workspace/contracts.ts +135 -0
- package/src/workspace/policy.ts +327 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { WorkflowInput, WorkflowResult } from '@capekai/types';
|
|
2
|
+
import { serviceKey } from '../kernel/service-key';
|
|
3
|
+
import type {
|
|
4
|
+
CapekPlugin,
|
|
5
|
+
PluginContext,
|
|
6
|
+
ToolDefinition as KernelToolDefinition,
|
|
7
|
+
} from '../kernel/types';
|
|
8
|
+
import {
|
|
9
|
+
DOMAIN_TOOL_PAYLOAD_FIELD,
|
|
10
|
+
registerDomainToolFallback,
|
|
11
|
+
type DomainToolPayload,
|
|
12
|
+
} from '../runtime/domain-tool-source';
|
|
13
|
+
import {
|
|
14
|
+
buildWorkflowToolDefinition,
|
|
15
|
+
canSpawnSubagent,
|
|
16
|
+
executeWorkflow,
|
|
17
|
+
executeWorkflowWithDeps,
|
|
18
|
+
getWorkflowToolDefinition,
|
|
19
|
+
type GetWorkflowToolDefinitionOptions,
|
|
20
|
+
type WorkflowExecutionOptions,
|
|
21
|
+
type WorkflowServiceDeps,
|
|
22
|
+
type WorkflowToolDefinition,
|
|
23
|
+
} from '../workflow/execution';
|
|
24
|
+
import {
|
|
25
|
+
capekSubagentDomainKey,
|
|
26
|
+
type SubagentDomainService,
|
|
27
|
+
} from './subagent-domain';
|
|
28
|
+
import {
|
|
29
|
+
capekOrchestratorSessionKey,
|
|
30
|
+
type OrchestratorSessionContract,
|
|
31
|
+
} from './service-keys';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* C5 workflow domain plugin. Owns the agent-scoped workflow service and the
|
|
35
|
+
* `workflow` tool contribution through the generic contributed-domain-tool
|
|
36
|
+
* seam. Decompose → fan out → synthesize runs over the scope-captured
|
|
37
|
+
* subagent domain service (leaf execution, depth, targets, subagent
|
|
38
|
+
* listing) and the shared `capek.orchestrator-session` contract; composed
|
|
39
|
+
* payloads never read module globals. The unscoped fallback keeps the
|
|
40
|
+
* pre-C5 module accessors and is installed explicitly, never at module
|
|
41
|
+
* load.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export const CURRENT_WORKFLOW_DOMAIN_PLUGIN_ID = 'current.workflow-domain';
|
|
45
|
+
export const WORKFLOW_TOOL_CONTRIBUTION_ID = 'workflow.workflow';
|
|
46
|
+
/** Between task (650) and session_search (700) so the composed tool order
|
|
47
|
+
* keeps the pre-C5 relative position: workflow before session_search and
|
|
48
|
+
* scheduler. */
|
|
49
|
+
export const WORKFLOW_TOOL_CONTRIBUTION_ORDER = 690;
|
|
50
|
+
|
|
51
|
+
export interface WorkflowDomainService {
|
|
52
|
+
readonly tools: readonly DomainToolPayload[];
|
|
53
|
+
/** Depth gate shared by the workflow tool payload. */
|
|
54
|
+
canSpawn(sessionId: string): boolean | Promise<boolean>;
|
|
55
|
+
resolveDefinition(
|
|
56
|
+
options: GetWorkflowToolDefinitionOptions,
|
|
57
|
+
): Promise<WorkflowToolDefinition | null>;
|
|
58
|
+
execute(input: WorkflowInput, options: WorkflowExecutionOptions): Promise<WorkflowResult>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const capekWorkflowDomainKey = serviceKey<WorkflowDomainService>(
|
|
62
|
+
'capek.workflow-domain',
|
|
63
|
+
'agent',
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
interface WorkflowPayloadDeps {
|
|
67
|
+
canSpawn(sessionId: string): boolean | Promise<boolean>;
|
|
68
|
+
resolveDefinition(
|
|
69
|
+
options: GetWorkflowToolDefinitionOptions,
|
|
70
|
+
): Promise<WorkflowToolDefinition | null>;
|
|
71
|
+
execute(input: WorkflowInput, options: WorkflowExecutionOptions): Promise<WorkflowResult>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function workflowPayload(deps: WorkflowPayloadDeps): DomainToolPayload {
|
|
75
|
+
const placeholder = buildWorkflowToolDefinition([]);
|
|
76
|
+
return {
|
|
77
|
+
name: placeholder.name,
|
|
78
|
+
description: placeholder.description,
|
|
79
|
+
inputSchema: placeholder.inputSchema,
|
|
80
|
+
isEnabled: async (workspaceId, sessionId) =>
|
|
81
|
+
typeof sessionId === 'string' && await deps.canSpawn(sessionId),
|
|
82
|
+
resolveDefinition: async (sessionId, options) => {
|
|
83
|
+
const definition = await deps.resolveDefinition({
|
|
84
|
+
sessionId,
|
|
85
|
+
canSpawnSubagents: options?.canSpawnSubagents as boolean | string[] | null | undefined,
|
|
86
|
+
allowSelfAsSubagent: options?.allowSelfAsSubagent as boolean | undefined,
|
|
87
|
+
});
|
|
88
|
+
if (!definition) return null;
|
|
89
|
+
return {
|
|
90
|
+
description: definition.description,
|
|
91
|
+
inputSchema: definition.inputSchema,
|
|
92
|
+
allowedSubagentIds: definition.allowedSubagentIds,
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
execute: async (input, context) => {
|
|
96
|
+
const workflowInput = {
|
|
97
|
+
prompt: input.prompt as string,
|
|
98
|
+
...(input.description ? { description: input.description as string } : {}),
|
|
99
|
+
...(input.subtasks ? { subtasks: input.subtasks as WorkflowInput['subtasks'] } : {}),
|
|
100
|
+
...(input.leafPreconfigId ? { leafPreconfigId: input.leafPreconfigId as string } : {}),
|
|
101
|
+
...(input.outputSchema ? { outputSchema: input.outputSchema as Record<string, unknown> } : {}),
|
|
102
|
+
} as WorkflowInput;
|
|
103
|
+
|
|
104
|
+
// No broadcast pass-through: pre-C5 leaves defaulted to the module
|
|
105
|
+
// broadcast, and composed leaves default to the subagent domain's
|
|
106
|
+
// scope-captured host delivery.
|
|
107
|
+
return deps.execute(workflowInput, {
|
|
108
|
+
sessionId: context.sessionId,
|
|
109
|
+
workspaceId: typeof context.workspaceId === 'string' ? context.workspaceId : undefined,
|
|
110
|
+
workspacePath: typeof context.workspacePath === 'string' ? context.workspacePath : undefined,
|
|
111
|
+
abortSignal: context.abortSignal as AbortSignal | undefined,
|
|
112
|
+
allowedSubagentIds: context.allowedSubagentIds as string[] | undefined,
|
|
113
|
+
}) as unknown as Promise<Record<string, unknown>>;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Unscoped compatibility payload: keeps the pre-C5 execute-time module
|
|
119
|
+
* accessors. Installed through `installWorkflowToolFallback`, never at
|
|
120
|
+
* module load. */
|
|
121
|
+
export function createWorkflowToolFallbackPayload(): DomainToolPayload {
|
|
122
|
+
return workflowPayload({
|
|
123
|
+
canSpawn: canSpawnSubagent,
|
|
124
|
+
resolveDefinition: (options) => getWorkflowToolDefinition(options),
|
|
125
|
+
execute: (input, options) => executeWorkflow(input, options),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Explicitly installs the unscoped legacy fallback. Called by the Jean2
|
|
130
|
+
* compatibility bindings installation (server bootstrap) and by focused
|
|
131
|
+
* tests; no module-load registration exists. */
|
|
132
|
+
export function installWorkflowToolFallback(): void {
|
|
133
|
+
registerDomainToolFallback('workflow', createWorkflowToolFallbackPayload());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function workflowDomainPlugin(id: string): CapekPlugin<unknown> {
|
|
137
|
+
return {
|
|
138
|
+
id,
|
|
139
|
+
scope: 'agent',
|
|
140
|
+
provides: [capekWorkflowDomainKey],
|
|
141
|
+
requires: [capekSubagentDomainKey, capekOrchestratorSessionKey],
|
|
142
|
+
setup(context: PluginContext) {
|
|
143
|
+
const subagent: SubagentDomainService = context.require(capekSubagentDomainKey);
|
|
144
|
+
const orchestrator: OrchestratorSessionContract = context.require(capekOrchestratorSessionKey);
|
|
145
|
+
|
|
146
|
+
const serviceDeps: WorkflowServiceDeps = {
|
|
147
|
+
canSpawn: subagent.canSpawnSubagent,
|
|
148
|
+
listSubagents: subagent.listSubagents,
|
|
149
|
+
executeLeaf: subagent.execute,
|
|
150
|
+
orchestrator,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const service: WorkflowDomainService = {
|
|
154
|
+
tools: [
|
|
155
|
+
workflowPayload({
|
|
156
|
+
canSpawn: async (sessionId) => subagent.canSpawnSubagent(sessionId),
|
|
157
|
+
resolveDefinition: async (options) => {
|
|
158
|
+
// The composed path resolves targets through the subagent
|
|
159
|
+
// domain service's scope-captured deps; it never reads module
|
|
160
|
+
// globals.
|
|
161
|
+
const maximumDepthReached = !(await subagent.canSpawnSubagent(options.sessionId));
|
|
162
|
+
return subagent.resolveTargets({
|
|
163
|
+
...options,
|
|
164
|
+
maximumDepthReached,
|
|
165
|
+
}).then((targets) =>
|
|
166
|
+
targets.length === 0 ? null : buildWorkflowToolDefinition(targets));
|
|
167
|
+
},
|
|
168
|
+
execute: (input, options) => executeWorkflowWithDeps(input, options, serviceDeps),
|
|
169
|
+
}),
|
|
170
|
+
],
|
|
171
|
+
canSpawn: async (sessionId) => subagent.canSpawnSubagent(sessionId),
|
|
172
|
+
resolveDefinition: async (options) => {
|
|
173
|
+
const maximumDepthReached = !(await subagent.canSpawnSubagent(options.sessionId));
|
|
174
|
+
return subagent.resolveTargets({
|
|
175
|
+
...options,
|
|
176
|
+
maximumDepthReached,
|
|
177
|
+
}).then((targets) =>
|
|
178
|
+
targets.length === 0 ? null : buildWorkflowToolDefinition(targets));
|
|
179
|
+
},
|
|
180
|
+
execute: (input, options) => executeWorkflowWithDeps(input, options, serviceDeps),
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
context.provide(capekWorkflowDomainKey, service);
|
|
184
|
+
context.contributeTool({
|
|
185
|
+
id: WORKFLOW_TOOL_CONTRIBUTION_ID,
|
|
186
|
+
order: WORKFLOW_TOOL_CONTRIBUTION_ORDER,
|
|
187
|
+
definition: {
|
|
188
|
+
name: service.tools[0].name,
|
|
189
|
+
description: service.tools[0].description,
|
|
190
|
+
inputSchema: service.tools[0].inputSchema,
|
|
191
|
+
timeout: 600000,
|
|
192
|
+
[DOMAIN_TOOL_PAYLOAD_FIELD]: service.tools[0],
|
|
193
|
+
} as KernelToolDefinition,
|
|
194
|
+
requiredCapabilities: [capekWorkflowDomainKey],
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { CapekPlugin, PluginContext } from '../kernel/types';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { SENSITIVE_FILE_PATTERNS } from '@capekai/types';
|
|
4
|
+
import {
|
|
5
|
+
BLOCKED_PATHS,
|
|
6
|
+
createWorkspaceService,
|
|
7
|
+
type WorkspacePolicyOptions,
|
|
8
|
+
} from '../workspace/policy';
|
|
9
|
+
import { capekWorkspacePolicyKey } from './service-keys';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* C6 provider for the agent-scoped workspace policy service
|
|
13
|
+
* (`capek.workspace-policy`). The path inputs translate into provider
|
|
14
|
+
* options here, at composition: the blocked-path list, the sensitive
|
|
15
|
+
* pattern list, and the home directory freeze into the service options, so
|
|
16
|
+
* no runtime code re-reads them. The default provider reproduces the exact
|
|
17
|
+
* current containment, root classification, expansion, and sensitive/blocked
|
|
18
|
+
* denial behavior.
|
|
19
|
+
*/
|
|
20
|
+
export function workspacePolicyPlugin(id: string): CapekPlugin<unknown> {
|
|
21
|
+
return {
|
|
22
|
+
id,
|
|
23
|
+
scope: 'agent',
|
|
24
|
+
provides: [capekWorkspacePolicyKey],
|
|
25
|
+
setup(context: PluginContext) {
|
|
26
|
+
const options: WorkspacePolicyOptions = {
|
|
27
|
+
blockedPaths: [...BLOCKED_PATHS],
|
|
28
|
+
sensitivePatterns: [...SENSITIVE_FILE_PATTERNS],
|
|
29
|
+
homeDir: homedir(),
|
|
30
|
+
};
|
|
31
|
+
context.provide(
|
|
32
|
+
capekWorkspacePolicyKey,
|
|
33
|
+
createWorkspaceService({ id, options }),
|
|
34
|
+
);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import type { ProviderStatus } from '@capekai/types';
|
|
3
|
+
import type {
|
|
4
|
+
ConnectableProvider,
|
|
5
|
+
ConnectOptions,
|
|
6
|
+
ConnectResult,
|
|
7
|
+
ModelFactoryOptions,
|
|
8
|
+
ModelFactoryResult,
|
|
9
|
+
} from './types';
|
|
10
|
+
|
|
11
|
+
const providers = new Map<string, ConnectableProvider>();
|
|
12
|
+
const scopedProviders = new AsyncLocalStorage<ReadonlyMap<string, ConnectableProvider>>();
|
|
13
|
+
|
|
14
|
+
function activeProvider(id: string): ConnectableProvider | undefined {
|
|
15
|
+
return scopedProviders.getStore()?.get(id) ?? providers.get(id);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function withProviderOverrides<T>(overrides: ReadonlyMap<string, ConnectableProvider>, callback: () => T): T {
|
|
19
|
+
return scopedProviders.run(overrides, callback);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function registerProvider(provider: ConnectableProvider): void {
|
|
23
|
+
providers.set(provider.descriptor.id, provider);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getConnectableProviders(): ConnectableProvider[] {
|
|
27
|
+
const combined = new Map(providers);
|
|
28
|
+
for (const [id, provider] of scopedProviders.getStore() ?? []) combined.set(id, provider);
|
|
29
|
+
return [...combined.values()];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getProvider(id: string): ConnectableProvider | undefined {
|
|
33
|
+
return activeProvider(id);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getProviderStatus(id: string): ProviderStatus {
|
|
37
|
+
return activeProvider(id)?.getStatus() ?? { provider: id, connected: false };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function connectProvider(id: string, options?: ConnectOptions): Promise<ConnectResult> {
|
|
41
|
+
const provider = activeProvider(id);
|
|
42
|
+
if (!provider) throw new Error(`Unknown connectable provider: ${id}`);
|
|
43
|
+
return provider.connect(options);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function disconnectProvider(id: string): Promise<void> {
|
|
47
|
+
const provider = activeProvider(id);
|
|
48
|
+
if (!provider) throw new Error(`Unknown connectable provider: ${id}`);
|
|
49
|
+
await provider.disconnect();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function createModelForProvider(options: ModelFactoryOptions): Promise<ModelFactoryResult> {
|
|
53
|
+
const provider = activeProvider(options.providerId);
|
|
54
|
+
if (!provider) throw new Error(`Unknown connectable provider: ${options.providerId}`);
|
|
55
|
+
if (!provider.createModel) {
|
|
56
|
+
throw new Error(`Provider '${options.providerId}' does not support creating models (kind: 'service')`);
|
|
57
|
+
}
|
|
58
|
+
return provider.createModel(options);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resetProviders(): void {
|
|
62
|
+
providers.clear();
|
|
63
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { LanguageModel } from 'ai';
|
|
2
|
+
import type { OAuthRedirectStrategy, ProviderDescriptor, ProviderStatus } from '@capekai/types';
|
|
3
|
+
|
|
4
|
+
export interface ModelFactoryOptions {
|
|
5
|
+
modelId: string;
|
|
6
|
+
providerId: string;
|
|
7
|
+
systemPrompt: string;
|
|
8
|
+
sessionId?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ModelFactoryResult {
|
|
12
|
+
model: LanguageModel;
|
|
13
|
+
useProviderInstructions?: boolean;
|
|
14
|
+
omitMaxOutputTokens?: boolean;
|
|
15
|
+
providerOptions?: Record<string, Record<string, unknown>>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ConnectOptions {
|
|
19
|
+
redirectStrategy?: OAuthRedirectStrategy;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ConnectResult {
|
|
23
|
+
authorizationUrl?: string;
|
|
24
|
+
flowId?: string;
|
|
25
|
+
redirectStrategy?: OAuthRedirectStrategy;
|
|
26
|
+
redirectUri?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TokenResponse {
|
|
30
|
+
access_token: string;
|
|
31
|
+
refresh_token: string;
|
|
32
|
+
expires_in?: number;
|
|
33
|
+
id_token?: string;
|
|
34
|
+
token_type?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ConnectableProvider {
|
|
38
|
+
descriptor: ProviderDescriptor;
|
|
39
|
+
getStatus(): ProviderStatus;
|
|
40
|
+
connect(options?: ConnectOptions): Promise<ConnectResult>;
|
|
41
|
+
disconnect(): Promise<void>;
|
|
42
|
+
onTokensReceived(tokens: TokenResponse): Promise<void>;
|
|
43
|
+
createModel?(options: ModelFactoryOptions): Promise<ModelFactoryResult>;
|
|
44
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C6 retry policy contract and default provider.
|
|
3
|
+
*
|
|
4
|
+
* The policy owns retry classification, backoff computation, circuit state,
|
|
5
|
+
* and the no-retry-after-tool-activity side-effect barrier. `core/retry.ts`
|
|
6
|
+
* is a pinned compatibility forwarder to `retry/stream-chat.ts`, which
|
|
7
|
+
* resolves the active policy through `getRetryPolicy()`: a composed agent
|
|
8
|
+
* scope seeds its own agent-scoped policy (own circuit map), and unscoped
|
|
9
|
+
* consumers (the current Jean2 server path) fall back to one lazily created
|
|
10
|
+
* process-default policy whose circuit state lives for the process lifetime,
|
|
11
|
+
* exactly like the pre-C6 module-global map.
|
|
12
|
+
*
|
|
13
|
+
* `withRetryCircuitState` is the compatibility overlay seam: it overrides
|
|
14
|
+
* only the circuit map for the callback duration, exactly like the pre-C6
|
|
15
|
+
* scoped-circuit ALS. The server retry tests and legacy consumers keep using
|
|
16
|
+
* it until C8 retires the compat surface.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
20
|
+
import type { ChatRetryErrorType } from '@capekai/types';
|
|
21
|
+
import {
|
|
22
|
+
ApiErrorType,
|
|
23
|
+
classifyApiError,
|
|
24
|
+
type ClassifiedError,
|
|
25
|
+
} from '../utils/errors';
|
|
26
|
+
|
|
27
|
+
export interface StreamRetryPolicy {
|
|
28
|
+
maxRetries?: number;
|
|
29
|
+
baseDelayMs?: number;
|
|
30
|
+
maxDelayMs?: number;
|
|
31
|
+
jitterRatio?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CircuitState {
|
|
35
|
+
failures: number;
|
|
36
|
+
lastFailureAt: number;
|
|
37
|
+
openUntil: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
41
|
+
const DEFAULT_BASE_DELAY_MS = 2_000;
|
|
42
|
+
const DEFAULT_MAX_DELAY_MS = 30_000;
|
|
43
|
+
const DEFAULT_JITTER_RATIO = 0.2;
|
|
44
|
+
const CIRCUIT_FAILURE_THRESHOLD = 3;
|
|
45
|
+
const CIRCUIT_FAILURE_WINDOW_MS = 60_000;
|
|
46
|
+
const CIRCUIT_COOLDOWN_MS = 30_000;
|
|
47
|
+
|
|
48
|
+
/** Inputs for the retry decision on one failed attempt. */
|
|
49
|
+
export interface RetryDecisionContext {
|
|
50
|
+
/** One-based attempt number of the failing attempt. */
|
|
51
|
+
retryNumber: number;
|
|
52
|
+
maxRetries: number;
|
|
53
|
+
classified: ClassifiedError;
|
|
54
|
+
attemptHadToolActivity: boolean;
|
|
55
|
+
aborted: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* C6 retry policy contract. Agent-scoped behind `capekRetryPolicyKey`. The
|
|
60
|
+
* default provider reproduces the exact pre-C6 behavior; a custom provider
|
|
61
|
+
* may vary the numeric defaults, the backoff curve, the circuit thresholds,
|
|
62
|
+
* or the side-effect barrier without the stream loop changing.
|
|
63
|
+
*/
|
|
64
|
+
export interface RetryPolicy {
|
|
65
|
+
readonly id: string;
|
|
66
|
+
/** Numeric defaults used when per-call policy options omit a field. */
|
|
67
|
+
readonly defaults: Readonly<Required<StreamRetryPolicy>>;
|
|
68
|
+
/** Resolves an already-classified error as-is, otherwise classifies it. */
|
|
69
|
+
classify(error: unknown): ClassifiedError;
|
|
70
|
+
/** Maps a classified error to the `chat.retry` errorType value. */
|
|
71
|
+
retryErrorType(classified: ClassifiedError): ChatRetryErrorType;
|
|
72
|
+
/** True when the failed attempt may be retried: retryable, retries remain,
|
|
73
|
+
* the attempt had no tool activity, and the run is not aborted. The
|
|
74
|
+
* tool-activity check is the side-effect barrier. */
|
|
75
|
+
canRetry(context: RetryDecisionContext): boolean;
|
|
76
|
+
/** Exponential backoff with jitter, honoring Retry-After as a minimum. */
|
|
77
|
+
calculateDelay(
|
|
78
|
+
retryNumber: number,
|
|
79
|
+
classified: ClassifiedError,
|
|
80
|
+
baseDelayMs: number,
|
|
81
|
+
maxDelayMs: number,
|
|
82
|
+
jitterRatio: number,
|
|
83
|
+
): number;
|
|
84
|
+
/** Abort-aware backoff wait; rejects with `RetryDelayAbortedError`. */
|
|
85
|
+
waitForRetry(delayMs: number, signal: AbortSignal): Promise<void>;
|
|
86
|
+
/** Circuit key derived from provider and model identity. */
|
|
87
|
+
circuitKey(providerId: string | null | undefined, modelId: string | null | undefined): string;
|
|
88
|
+
/** Milliseconds until the open circuit for the key closes, or 0. */
|
|
89
|
+
openCircuitRemainingMs(key: string): number;
|
|
90
|
+
/** Records a failed attempt; returns whether the circuit just opened. */
|
|
91
|
+
recordCircuitFailure(key: string): boolean;
|
|
92
|
+
/** Closes the circuit for the key (successful stream or expiry). */
|
|
93
|
+
resetCircuit(key: string): void;
|
|
94
|
+
/** Message override for the exhausted `chat.retry` event, or null to keep
|
|
95
|
+
* the classified error message. */
|
|
96
|
+
exhaustedMessage(context: {
|
|
97
|
+
attemptHadToolActivity: boolean;
|
|
98
|
+
circuitOpened: boolean;
|
|
99
|
+
}): string | null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class RetryDelayAbortedError extends Error {
|
|
103
|
+
constructor() {
|
|
104
|
+
super('Retry delay aborted');
|
|
105
|
+
this.name = 'RetryDelayAbortedError';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isClassifiedError(err: unknown): err is ClassifiedError {
|
|
110
|
+
if (typeof err !== 'object' || err === null) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const candidate = err as Record<string, unknown>;
|
|
115
|
+
return (
|
|
116
|
+
typeof candidate.type === 'string'
|
|
117
|
+
&& typeof candidate.retryable === 'boolean'
|
|
118
|
+
&& typeof candidate.message === 'string'
|
|
119
|
+
&& 'originalError' in candidate
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function toRetryErrorType(type: ApiErrorType): ChatRetryErrorType {
|
|
124
|
+
if (type === ApiErrorType.RateLimit) return 'rate_limit';
|
|
125
|
+
if (type === ApiErrorType.Timeout) return 'timeout';
|
|
126
|
+
if (type === ApiErrorType.Network) return 'network';
|
|
127
|
+
return 'server_error';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Creates an empty circuit state map. Kept as the compat seam used by the
|
|
131
|
+
* server retry tests and by `withRetryCircuitState`. */
|
|
132
|
+
export function createRetryCircuitState(): Map<string, CircuitState> {
|
|
133
|
+
return new Map<string, CircuitState>();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface RetryPolicyOptions {
|
|
137
|
+
id?: string;
|
|
138
|
+
/** When omitted the policy owns a fresh map for its lifetime. */
|
|
139
|
+
circuitState?: Map<string, CircuitState>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The C6 default provider wrapping the exact pre-C6 behavior. */
|
|
143
|
+
export function createRetryPolicy(options: RetryPolicyOptions = {}): RetryPolicy {
|
|
144
|
+
const id = options.id ?? 'retry.default';
|
|
145
|
+
const circuitState = options.circuitState ?? createRetryCircuitState();
|
|
146
|
+
|
|
147
|
+
function activeStateMap(): Map<string, CircuitState> {
|
|
148
|
+
return scopedCircuitState.getStore() ?? circuitState;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const policy: RetryPolicy = {
|
|
152
|
+
id,
|
|
153
|
+
defaults: {
|
|
154
|
+
maxRetries: DEFAULT_MAX_RETRIES,
|
|
155
|
+
baseDelayMs: DEFAULT_BASE_DELAY_MS,
|
|
156
|
+
maxDelayMs: DEFAULT_MAX_DELAY_MS,
|
|
157
|
+
jitterRatio: DEFAULT_JITTER_RATIO,
|
|
158
|
+
},
|
|
159
|
+
classify(error: unknown): ClassifiedError {
|
|
160
|
+
return isClassifiedError(error) ? error : classifyApiError(error);
|
|
161
|
+
},
|
|
162
|
+
retryErrorType(classified: ClassifiedError): ChatRetryErrorType {
|
|
163
|
+
return toRetryErrorType(classified.type);
|
|
164
|
+
},
|
|
165
|
+
canRetry(context: RetryDecisionContext): boolean {
|
|
166
|
+
return context.classified.retryable
|
|
167
|
+
&& context.retryNumber <= context.maxRetries
|
|
168
|
+
&& !context.attemptHadToolActivity
|
|
169
|
+
&& !context.aborted;
|
|
170
|
+
},
|
|
171
|
+
calculateDelay(
|
|
172
|
+
retryNumber: number,
|
|
173
|
+
classified: ClassifiedError,
|
|
174
|
+
baseDelayMs: number,
|
|
175
|
+
maxDelayMs: number,
|
|
176
|
+
jitterRatio: number,
|
|
177
|
+
): number {
|
|
178
|
+
const exponentialDelay = Math.min(baseDelayMs * 2 ** (retryNumber - 1), maxDelayMs);
|
|
179
|
+
const jitterRange = exponentialDelay * jitterRatio;
|
|
180
|
+
const jitteredDelay = Math.round(exponentialDelay - jitterRange + Math.random() * jitterRange * 2);
|
|
181
|
+
return Math.max(jitteredDelay, classified.retryAfterMs ?? 0);
|
|
182
|
+
},
|
|
183
|
+
waitForRetry(delayMs: number, signal: AbortSignal): Promise<void> {
|
|
184
|
+
if (signal.aborted) {
|
|
185
|
+
return Promise.reject(new RetryDelayAbortedError());
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return new Promise((resolve, reject) => {
|
|
189
|
+
const timeout = setTimeout(() => {
|
|
190
|
+
signal.removeEventListener('abort', onAbort);
|
|
191
|
+
resolve();
|
|
192
|
+
}, delayMs);
|
|
193
|
+
const onAbort = () => {
|
|
194
|
+
clearTimeout(timeout);
|
|
195
|
+
signal.removeEventListener('abort', onAbort);
|
|
196
|
+
reject(new RetryDelayAbortedError());
|
|
197
|
+
};
|
|
198
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
circuitKey(providerId: string | null | undefined, modelId: string | null | undefined): string {
|
|
202
|
+
return `${providerId ?? 'default'}:${modelId ?? 'default'}`;
|
|
203
|
+
},
|
|
204
|
+
openCircuitRemainingMs(key: string): number {
|
|
205
|
+
const states = activeStateMap();
|
|
206
|
+
const state = states.get(key);
|
|
207
|
+
if (!state) return 0;
|
|
208
|
+
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
if (state.openUntil === 0) {
|
|
211
|
+
if (now - state.lastFailureAt > CIRCUIT_FAILURE_WINDOW_MS) {
|
|
212
|
+
states.delete(key);
|
|
213
|
+
}
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
if (state.openUntil <= now) {
|
|
217
|
+
states.delete(key);
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
return state.openUntil - now;
|
|
221
|
+
},
|
|
222
|
+
recordCircuitFailure(key: string): boolean {
|
|
223
|
+
const now = Date.now();
|
|
224
|
+
const states = activeStateMap();
|
|
225
|
+
const previous = states.get(key);
|
|
226
|
+
const previousFailures = previous && now - previous.lastFailureAt <= CIRCUIT_FAILURE_WINDOW_MS
|
|
227
|
+
? previous.failures
|
|
228
|
+
: 0;
|
|
229
|
+
const failures = previousFailures + 1;
|
|
230
|
+
const openUntil = failures >= CIRCUIT_FAILURE_THRESHOLD
|
|
231
|
+
? now + CIRCUIT_COOLDOWN_MS
|
|
232
|
+
: 0;
|
|
233
|
+
states.set(key, { failures, lastFailureAt: now, openUntil });
|
|
234
|
+
return openUntil > 0;
|
|
235
|
+
},
|
|
236
|
+
resetCircuit(key: string): void {
|
|
237
|
+
activeStateMap().delete(key);
|
|
238
|
+
},
|
|
239
|
+
exhaustedMessage(context: { attemptHadToolActivity: boolean; circuitOpened: boolean }): string | null {
|
|
240
|
+
if (context.attemptHadToolActivity) {
|
|
241
|
+
return 'Automatic retry stopped because the failed attempt used a tool and replay could duplicate side effects.';
|
|
242
|
+
}
|
|
243
|
+
if (context.circuitOpened) {
|
|
244
|
+
return 'Automatic retry stopped after repeated provider failures.';
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
return policy;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const scopedPolicy = new AsyncLocalStorage<RetryPolicy>();
|
|
253
|
+
const scopedCircuitState = new AsyncLocalStorage<Map<string, CircuitState>>();
|
|
254
|
+
let processDefaultPolicy: RetryPolicy | undefined;
|
|
255
|
+
|
|
256
|
+
/** Resolves the policy seeded for the active agent scope, falling back to one
|
|
257
|
+
* lazily created process-default policy for consumers that run outside a
|
|
258
|
+
* composed scope (the current Jean2 server path). The process default keeps
|
|
259
|
+
* the exact pre-C6 process-wide circuit behavior. */
|
|
260
|
+
export function getRetryPolicy(): RetryPolicy {
|
|
261
|
+
return scopedPolicy.getStore()
|
|
262
|
+
?? (processDefaultPolicy ??= createRetryPolicy({ id: 'retry.process-default' }));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Seeds a policy for the callback duration. `enterAgentScope` seeds the
|
|
266
|
+
* composed agent scope's policy here. */
|
|
267
|
+
export function withRetryPolicy<T>(policy: RetryPolicy, callback: () => T): T {
|
|
268
|
+
return scopedPolicy.run(policy, callback);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Compatibility overlay: replaces only the circuit map for the callback
|
|
272
|
+
* duration, exactly like the pre-C6 scoped-circuit AsyncLocalStorage. */
|
|
273
|
+
export function withRetryCircuitState<T>(state: Map<string, CircuitState>, callback: () => T): T {
|
|
274
|
+
return scopedCircuitState.run(state, callback);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Test-only reset of the lazily created process default so unscoped-path
|
|
278
|
+
* tests never leak circuit state across cases. Exported from this module
|
|
279
|
+
* only; no package subpath re-exports it. */
|
|
280
|
+
export function resetDefaultRetryPolicyForTests(): void {
|
|
281
|
+
processDefaultPolicy = undefined;
|
|
282
|
+
}
|