@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.
Files changed (148) hide show
  1. package/README.md +12 -0
  2. package/package.json +105 -0
  3. package/src/adapters/ai-sdk.ts +84 -0
  4. package/src/compaction/contracts.ts +82 -0
  5. package/src/compaction/executor.ts +161 -0
  6. package/src/compaction/policy.ts +318 -0
  7. package/src/compaction/recovery.ts +139 -0
  8. package/src/compaction/task.ts +540 -0
  9. package/src/configuration/contracts.ts +58 -0
  10. package/src/configuration/defaults.ts +27 -0
  11. package/src/configuration/runtime.ts +42 -0
  12. package/src/configuration/single-model.ts +75 -0
  13. package/src/context/assembler.ts +112 -0
  14. package/src/context/index.ts +2 -0
  15. package/src/context/sources.ts +119 -0
  16. package/src/context/workspace.ts +63 -0
  17. package/src/core/agent.ts +401 -0
  18. package/src/core/build-tools.ts +139 -0
  19. package/src/core/chat-handler.ts +858 -0
  20. package/src/core/error-handling.ts +18 -0
  21. package/src/core/fork.ts +103 -0
  22. package/src/core/interrupt.ts +192 -0
  23. package/src/core/message-utils.ts +261 -0
  24. package/src/core/model-utils.ts +149 -0
  25. package/src/core/part-utils.ts +88 -0
  26. package/src/core/provider-utils.ts +67 -0
  27. package/src/core/revert.ts +46 -0
  28. package/src/core/step-handlers.ts +157 -0
  29. package/src/core/stream/finalization.ts +65 -0
  30. package/src/core/stream/stream-config.ts +82 -0
  31. package/src/core/stream-handlers.ts +242 -0
  32. package/src/core/structured-output.ts +68 -0
  33. package/src/core/tool-builders/agent-tools.ts +71 -0
  34. package/src/core/tool-builders/external-tools.ts +179 -0
  35. package/src/core/tool-builders/types.ts +16 -0
  36. package/src/core/tool-builders/workspace-tools.ts +293 -0
  37. package/src/core/tool-capabilities.ts +65 -0
  38. package/src/goals/evaluator.ts +171 -0
  39. package/src/goals/index.ts +3 -0
  40. package/src/goals/loop.ts +167 -0
  41. package/src/goals/service.ts +39 -0
  42. package/src/index.ts +10 -0
  43. package/src/internal/ask-authority.ts +29 -0
  44. package/src/internal/composition.ts +44 -0
  45. package/src/internal/configuration.ts +22 -0
  46. package/src/internal/execution.ts +108 -0
  47. package/src/internal/hosts.ts +64 -0
  48. package/src/internal/plugins.ts +71 -0
  49. package/src/internal/providers.ts +32 -0
  50. package/src/internal/sandbox.ts +19 -0
  51. package/src/internal/tools.ts +48 -0
  52. package/src/internal/workspace.ts +25 -0
  53. package/src/kernel/diagnostics.ts +249 -0
  54. package/src/kernel/errors.ts +120 -0
  55. package/src/kernel/events.ts +82 -0
  56. package/src/kernel/index.ts +72 -0
  57. package/src/kernel/kernel.ts +62 -0
  58. package/src/kernel/lifecycle.ts +72 -0
  59. package/src/kernel/plugin.ts +218 -0
  60. package/src/kernel/registry.ts +493 -0
  61. package/src/kernel/scope.ts +776 -0
  62. package/src/kernel/service-key.ts +19 -0
  63. package/src/kernel/types.ts +317 -0
  64. package/src/memory/index.ts +2 -0
  65. package/src/memory/memory-tool.ts +75 -0
  66. package/src/memory/registry.ts +172 -0
  67. package/src/permission/ask-user-api.ts +70 -0
  68. package/src/permission/contracts.ts +135 -0
  69. package/src/permission/permission-request-manager.ts +58 -0
  70. package/src/permission/policy.ts +277 -0
  71. package/src/permission/runtime.ts +612 -0
  72. package/src/plugins/compaction-policy.ts +46 -0
  73. package/src/plugins/compose.ts +171 -0
  74. package/src/plugins/context-sections.ts +246 -0
  75. package/src/plugins/default-agent-driver.ts +14 -0
  76. package/src/plugins/facade-plugins.ts +129 -0
  77. package/src/plugins/goal-domain.ts +82 -0
  78. package/src/plugins/legacy-system-message.ts +152 -0
  79. package/src/plugins/loaded-tools.ts +23 -0
  80. package/src/plugins/memory-domain.ts +264 -0
  81. package/src/plugins/orchestrator-session.ts +29 -0
  82. package/src/plugins/permission-policy.ts +49 -0
  83. package/src/plugins/retry-policy.ts +28 -0
  84. package/src/plugins/scheduler-domain.ts +192 -0
  85. package/src/plugins/service-keys.ts +294 -0
  86. package/src/plugins/session-search-domain.ts +238 -0
  87. package/src/plugins/skills-domain.ts +272 -0
  88. package/src/plugins/subagent-domain.ts +287 -0
  89. package/src/plugins/tool-catalog.ts +78 -0
  90. package/src/plugins/tool-output-policy.ts +52 -0
  91. package/src/plugins/value-plugins.ts +150 -0
  92. package/src/plugins/workflow-domain.ts +198 -0
  93. package/src/plugins/workspace-policy.ts +37 -0
  94. package/src/providers/registry.ts +63 -0
  95. package/src/providers/types.ts +44 -0
  96. package/src/retry/policy.ts +282 -0
  97. package/src/retry/stream-chat.ts +312 -0
  98. package/src/runtime/agent-runtime.ts +83 -0
  99. package/src/runtime/default-agent-driver.ts +23 -0
  100. package/src/runtime/domain-tool-source.ts +156 -0
  101. package/src/runtime/events.ts +61 -0
  102. package/src/runtime/host-dependencies.ts +71 -0
  103. package/src/runtime/host-guidance.ts +22 -0
  104. package/src/runtime/host-layout.ts +23 -0
  105. package/src/runtime/host.ts +129 -0
  106. package/src/runtime/standalone-host.ts +118 -0
  107. package/src/sandbox/controller.ts +204 -0
  108. package/src/sandbox/model.ts +305 -0
  109. package/src/sandbox/provider.ts +53 -0
  110. package/src/sandbox/types.ts +110 -0
  111. package/src/scheduler/host.ts +22 -0
  112. package/src/scheduler/scheduler-tool.ts +172 -0
  113. package/src/session-search/host.ts +56 -0
  114. package/src/session-search/index.ts +23 -0
  115. package/src/session-search/session-search-tool.ts +151 -0
  116. package/src/skills/index.ts +3 -0
  117. package/src/skills/registry.ts +63 -0
  118. package/src/skills/skill-manage-tool.ts +205 -0
  119. package/src/skills/skill-tool.ts +42 -0
  120. package/src/storage/contracts.ts +159 -0
  121. package/src/storage/memory.ts +321 -0
  122. package/src/storage/options.ts +75 -0
  123. package/src/storage/runtime.ts +115 -0
  124. package/src/storage/sqlite-tool-output-artifacts.ts +106 -0
  125. package/src/storage/sqlite.ts +321 -0
  126. package/src/storage/tool-output-artifacts.ts +75 -0
  127. package/src/storage.ts +31 -0
  128. package/src/subagent/child-session.ts +282 -0
  129. package/src/subagent/guidance.ts +8 -0
  130. package/src/subagent/policy.ts +198 -0
  131. package/src/subagent/task-tool.ts +584 -0
  132. package/src/tool-output/contracts.ts +111 -0
  133. package/src/tool-output/policy.ts +410 -0
  134. package/src/tool.ts +1 -0
  135. package/src/tools/executor.ts +258 -0
  136. package/src/tools/install-manifest.ts +40 -0
  137. package/src/tools/llm-api.ts +77 -0
  138. package/src/tools/registry.ts +206 -0
  139. package/src/tools/tool-artifact.ts +182 -0
  140. package/src/tools/tool-source.ts +53 -0
  141. package/src/utils/errors.ts +334 -0
  142. package/src/utils/strip-visualization.ts +50 -0
  143. package/src/workflow/decomposer.ts +139 -0
  144. package/src/workflow/execution.ts +523 -0
  145. package/src/workflow/orchestrator-session.ts +161 -0
  146. package/src/workflow/synthesizer.ts +130 -0
  147. package/src/workspace/contracts.ts +135 -0
  148. 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
+ }