@bluecopa/harness 0.1.0-snapshot.33 → 0.1.0-snapshot.35
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/package.json +1 -1
- package/src/arc/agent-runner.ts +66 -14
- package/src/arc/arc-loop.ts +27 -48
- package/src/arc/arc-types.ts +2 -0
- package/src/arc/profile-builder.ts +157 -0
- package/src/arc/sig.ts +120 -0
- package/src/arc/skill-resolver.ts +78 -0
- package/src/arc/stores/rxdb-setup.ts +1 -0
- package/src/arc/types.ts +67 -14
package/package.json
CHANGED
package/src/arc/agent-runner.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { generateText } from 'ai';
|
|
2
|
+
import { generateText, generateObject } from 'ai';
|
|
3
3
|
import { anthropic } from '@ai-sdk/anthropic';
|
|
4
4
|
import type { AgentMessage, ToolCallAction } from '../agent/types';
|
|
5
5
|
import { getTextContent } from '../agent/types';
|
|
@@ -193,6 +193,11 @@ export interface AgentRunnerConfig {
|
|
|
193
193
|
seed?: AgentMessage[];
|
|
194
194
|
inbox?: AsyncIterable<AgentMessage>;
|
|
195
195
|
onActivity?: (activity: Activity) => void;
|
|
196
|
+
/** Allowed tool names for executor-level validation (defense-in-depth). */
|
|
197
|
+
allowedToolNames?: string[];
|
|
198
|
+
/** Zod schema for structured output. When set, the terminal step uses generateObject instead of generateText. */
|
|
199
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
200
|
+
outputSchema?: import('zod').ZodObject<any>;
|
|
196
201
|
|
|
197
202
|
// Runtime extras
|
|
198
203
|
hookRunner?: HookRunner;
|
|
@@ -211,6 +216,8 @@ export interface AgentRunResult {
|
|
|
211
216
|
messages: AgentMessage[];
|
|
212
217
|
output: string;
|
|
213
218
|
steps: number;
|
|
219
|
+
/** Structured output from generateObject when outputSchema is set. */
|
|
220
|
+
structuredOutput?: Record<string, unknown>;
|
|
214
221
|
}
|
|
215
222
|
|
|
216
223
|
export class AgentRunner {
|
|
@@ -270,6 +277,24 @@ export class AgentRunner {
|
|
|
270
277
|
if (toolCalls.length === 0) {
|
|
271
278
|
const text = result.text?.trim() ?? 'Done.';
|
|
272
279
|
messages.push({ role: 'assistant', content: text });
|
|
280
|
+
|
|
281
|
+
// Structured output: use generateObject on terminal step when schema is set
|
|
282
|
+
if (config.outputSchema) {
|
|
283
|
+
try {
|
|
284
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
285
|
+
const structured = await (generateObject as any)({
|
|
286
|
+
model: anthropic(config.model),
|
|
287
|
+
schema: config.outputSchema,
|
|
288
|
+
messages: toModelMessages(messages),
|
|
289
|
+
system: [{ role: 'system' as const, content: config.systemPrompt }],
|
|
290
|
+
abortSignal: config.signal,
|
|
291
|
+
});
|
|
292
|
+
return { messages, output: text, steps: step + 1, structuredOutput: structured.object };
|
|
293
|
+
} catch {
|
|
294
|
+
// Structured extraction failed — fall back to text output
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
273
298
|
return { messages, output: text, steps: step + 1 };
|
|
274
299
|
}
|
|
275
300
|
|
|
@@ -293,6 +318,22 @@ export class AgentRunner {
|
|
|
293
318
|
toolCallId: tc.toolCallId,
|
|
294
319
|
};
|
|
295
320
|
|
|
321
|
+
// Layer 2: executor-level tool validation (defense-in-depth)
|
|
322
|
+
if (config.allowedToolNames && !config.allowedToolNames.includes(tc.toolName)) {
|
|
323
|
+
const resultText = `ERROR: Tool "${tc.toolName}" is not available in this profile.`;
|
|
324
|
+
messages.push({
|
|
325
|
+
role: 'tool',
|
|
326
|
+
content: resultText,
|
|
327
|
+
toolResults: [{
|
|
328
|
+
toolCallId: tc.toolCallId,
|
|
329
|
+
toolName: tc.toolName,
|
|
330
|
+
result: resultText,
|
|
331
|
+
isError: true,
|
|
332
|
+
}],
|
|
333
|
+
});
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
|
|
296
337
|
config.onActivity?.({
|
|
297
338
|
type: 'tool_start',
|
|
298
339
|
name: tc.toolName,
|
|
@@ -368,6 +409,13 @@ export interface CreateProcessConfig {
|
|
|
368
409
|
processSystemPrompt?: string;
|
|
369
410
|
/** Async skill instructions to prepend to system prompt (resolved during process startup). */
|
|
370
411
|
skillPromptPromise?: Promise<string | null>;
|
|
412
|
+
/** Allowed tool names for executor-level validation (defense-in-depth against hallucinated tool calls). */
|
|
413
|
+
allowedToolNames?: string[];
|
|
414
|
+
/** Zod schema for structured output on the terminal step. */
|
|
415
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
416
|
+
outputSchema?: import('zod').ZodObject<any>;
|
|
417
|
+
/** Few-shot demo messages prepended before context episodes. */
|
|
418
|
+
demoMessages?: AgentMessage[];
|
|
371
419
|
|
|
372
420
|
// Runtime extras
|
|
373
421
|
hookRunner?: HookRunner;
|
|
@@ -421,7 +469,10 @@ export function createProcess(
|
|
|
421
469
|
void (async () => {
|
|
422
470
|
try {
|
|
423
471
|
process.status = 'running';
|
|
424
|
-
const seed =
|
|
472
|
+
const seed = [
|
|
473
|
+
...(config.demoMessages ?? []),
|
|
474
|
+
...(await seedPromise),
|
|
475
|
+
];
|
|
425
476
|
|
|
426
477
|
// Build system prompt: base + optional skill instructions
|
|
427
478
|
let systemPrompt = config.processSystemPrompt ?? PROCESS_SYSTEM_PROMPT;
|
|
@@ -454,6 +505,8 @@ export function createProcess(
|
|
|
454
505
|
'askUser',
|
|
455
506
|
'tellUser',
|
|
456
507
|
'downloadRawFile',
|
|
508
|
+
'allowedToolNames',
|
|
509
|
+
'outputSchema',
|
|
457
510
|
]),
|
|
458
511
|
}),
|
|
459
512
|
timeoutPromise(config.processTimeout),
|
|
@@ -462,7 +515,7 @@ export function createProcess(
|
|
|
462
515
|
const durationMs = Date.now() - startTime;
|
|
463
516
|
const nextIndex = await getNextEpisodeIndex(config.episodeStore, config.taskId);
|
|
464
517
|
|
|
465
|
-
const
|
|
518
|
+
const compressInput = {
|
|
466
519
|
taskId: config.taskId,
|
|
467
520
|
sessionId: config.sessionId,
|
|
468
521
|
index: nextIndex,
|
|
@@ -471,7 +524,14 @@ export function createProcess(
|
|
|
471
524
|
model,
|
|
472
525
|
parentEpisodeIds: request.contextEpisodeIds ?? [],
|
|
473
526
|
success: true,
|
|
474
|
-
}
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const { episode, trace, artifacts } = compressor.compress(compressInput);
|
|
530
|
+
|
|
531
|
+
// Attach structured output from generateObject if present
|
|
532
|
+
if (result.structuredOutput) {
|
|
533
|
+
episode.structuredOutput = result.structuredOutput;
|
|
534
|
+
}
|
|
475
535
|
|
|
476
536
|
await config.episodeStore.addEpisode(episode);
|
|
477
537
|
await config.episodeStore.addTrace(trace);
|
|
@@ -490,18 +550,10 @@ export function createProcess(
|
|
|
490
550
|
outbox.push({ type: 'episode', episode });
|
|
491
551
|
outbox.push({ type: 'done', result: processResult });
|
|
492
552
|
|
|
553
|
+
// Async LLM upgrade for long episodes
|
|
493
554
|
if (episode.steps > 10) {
|
|
494
555
|
const upgradeAc = new AbortController();
|
|
495
|
-
void compressor.compressLLM({
|
|
496
|
-
taskId: config.taskId,
|
|
497
|
-
sessionId: config.sessionId,
|
|
498
|
-
index: nextIndex,
|
|
499
|
-
threadAction: request.action,
|
|
500
|
-
messages: result.messages,
|
|
501
|
-
model,
|
|
502
|
-
parentEpisodeIds: request.contextEpisodeIds ?? [],
|
|
503
|
-
success: true,
|
|
504
|
-
}, upgradeAc.signal).then(async (upgraded) => {
|
|
556
|
+
void compressor.compressLLM(compressInput, upgradeAc.signal).then(async (upgraded) => {
|
|
505
557
|
episode.summary = upgraded.episode.summary;
|
|
506
558
|
await config.episodeStore.addEpisode(episode);
|
|
507
559
|
}).catch(() => { /* template summary remains */ });
|
package/src/arc/arc-loop.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
Turn,
|
|
18
18
|
TraceEvent,
|
|
19
19
|
} from './types';
|
|
20
|
+
import { isProfileDeclaration } from './types';
|
|
20
21
|
import type { ResiliencePolicy, ExecutionContext } from './resilience/types';
|
|
21
22
|
import { toModelMessages, estimateTokens } from './message-convert';
|
|
22
23
|
import { orchestratorTools } from './tools';
|
|
@@ -27,9 +28,9 @@ import { createProcess, firstEvent } from './agent-runner';
|
|
|
27
28
|
import { EpisodeCompressor } from './episode-compressor';
|
|
28
29
|
import { runConsolidation } from './consolidation';
|
|
29
30
|
import { pickDefined } from './utils';
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import
|
|
31
|
+
import type { SkillResolver } from './skill-resolver';
|
|
32
|
+
import { DefaultSkillResolver } from './skill-resolver';
|
|
33
|
+
import { resolveProfile } from './profile-builder';
|
|
33
34
|
|
|
34
35
|
// ── Default orchestrator prompt ──
|
|
35
36
|
|
|
@@ -81,9 +82,7 @@ export class ArcLoop {
|
|
|
81
82
|
/** Maps normalized action text → process ID for dedup. */
|
|
82
83
|
private readonly actionIndex = new Map<string, string>();
|
|
83
84
|
private readonly processListeners: Promise<void>[] = [];
|
|
84
|
-
private readonly
|
|
85
|
-
private skillSummaries: SkillSummary[] | null = null;
|
|
86
|
-
private skillSummariesPromise: Promise<SkillSummary[]> | null = null;
|
|
85
|
+
private readonly skillResolver: SkillResolver | undefined;
|
|
87
86
|
|
|
88
87
|
constructor(config: ArcLoopConfig) {
|
|
89
88
|
this.config = config;
|
|
@@ -124,14 +123,10 @@ export class ArcLoop {
|
|
|
124
123
|
this.resilience = config.resilience;
|
|
125
124
|
this.traceWriter = (config as ArcLoopConfig & { traceWriter?: (event: TraceEvent) => void }).traceWriter;
|
|
126
125
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
.then(fs => fs.readFile(config.skillIndexPath!, 'utf-8'))
|
|
132
|
-
.then(raw => JSON.parse(raw) as SkillSummary[])
|
|
133
|
-
.catch(() => []);
|
|
134
|
-
}
|
|
126
|
+
this.skillResolver = config.skillResolver
|
|
127
|
+
?? (config.skillIndexPath
|
|
128
|
+
? new DefaultSkillResolver({ skillIndexPath: config.skillIndexPath })
|
|
129
|
+
: undefined);
|
|
135
130
|
}
|
|
136
131
|
|
|
137
132
|
private trace(kind: TraceEvent['kind']): void {
|
|
@@ -546,18 +541,28 @@ export class ArcLoop {
|
|
|
546
541
|
// ── Process dispatch ──
|
|
547
542
|
|
|
548
543
|
private dispatch(request: ProcessRequest, parentSignal: AbortSignal): Process {
|
|
549
|
-
const
|
|
544
|
+
const profileConfig = request.profile
|
|
550
545
|
? this.config.processProfiles?.[request.profile]
|
|
551
546
|
: undefined;
|
|
547
|
+
|
|
548
|
+
// Resolve ProfileDeclaration → ProcessProfile (legacy profiles pass through)
|
|
549
|
+
const globalTools = (this.config.processTools ?? builtinTools) as Record<string, import('./arc-types').AnyTool>;
|
|
550
|
+
const profile = profileConfig
|
|
551
|
+
? resolveProfile(profileConfig, globalTools)
|
|
552
|
+
: undefined;
|
|
553
|
+
|
|
552
554
|
const defaultModel = resolveModel(
|
|
553
555
|
profile?.model ?? 'medium',
|
|
554
556
|
this.modelMap,
|
|
555
557
|
this.modelMap.medium,
|
|
556
558
|
);
|
|
557
559
|
|
|
558
|
-
// Resolve skill instructions
|
|
559
|
-
const
|
|
560
|
-
?
|
|
560
|
+
// Resolve skill instructions, constrained to profile's allowed skills (if declared)
|
|
561
|
+
const profileSkills = profileConfig && isProfileDeclaration(profileConfig)
|
|
562
|
+
? profileConfig.skills
|
|
563
|
+
: undefined;
|
|
564
|
+
const skillPromptPromise = this.skillResolver
|
|
565
|
+
? this.skillResolver.resolve(request.action, profileSkills).then(r => r?.systemPrompt ?? null)
|
|
561
566
|
: undefined;
|
|
562
567
|
|
|
563
568
|
const proc = createProcess(request, {
|
|
@@ -570,8 +575,11 @@ export class ArcLoop {
|
|
|
570
575
|
processMaxSteps: profile?.maxSteps ?? this.config.processMaxSteps ?? 20,
|
|
571
576
|
processTimeout: this.config.processTimeout ?? 120_000,
|
|
572
577
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
573
|
-
processTools: (profile?.tools ??
|
|
578
|
+
processTools: (profile?.tools ?? globalTools) as any,
|
|
574
579
|
processSystemPrompt: profile?.systemPrompt ?? this.config.processSystemPrompt,
|
|
580
|
+
allowedToolNames: profile?.allowedToolNames,
|
|
581
|
+
outputSchema: profile?.outputSchema,
|
|
582
|
+
demoMessages: profile?.demoMessages,
|
|
575
583
|
skillPromptPromise,
|
|
576
584
|
parentSignal,
|
|
577
585
|
...pickDefined(this.config, [
|
|
@@ -587,35 +595,6 @@ export class ArcLoop {
|
|
|
587
595
|
return proc;
|
|
588
596
|
}
|
|
589
597
|
|
|
590
|
-
/** Resolve skill instructions for a process action. Returns null if no skill matched. */
|
|
591
|
-
private async resolveSkillPrompt(action: string): Promise<string | null> {
|
|
592
|
-
if (!this.skillRouter || !this.skillSummariesPromise) return null;
|
|
593
|
-
|
|
594
|
-
// Ensure summaries are loaded
|
|
595
|
-
if (!this.skillSummaries) {
|
|
596
|
-
this.skillSummaries = await this.skillSummariesPromise;
|
|
597
|
-
}
|
|
598
|
-
if (this.skillSummaries.length === 0) return null;
|
|
599
|
-
|
|
600
|
-
// Fast match only (keyword + alias, no LLM call)
|
|
601
|
-
const matched = await this.skillRouter.selectSkill(action, this.skillSummaries);
|
|
602
|
-
if (matched) {
|
|
603
|
-
console.log(`[skill-router] Matched skill "${matched.name}" for action: "${action.slice(0, 80)}..."`);
|
|
604
|
-
} else {
|
|
605
|
-
console.log(`[skill-router] No match for action: "${action.slice(0, 80)}..."`);
|
|
606
|
-
}
|
|
607
|
-
if (!matched) return null;
|
|
608
|
-
|
|
609
|
-
try {
|
|
610
|
-
const skill = await loadSkillFromFile(matched.path);
|
|
611
|
-
console.log(`[skill-router] Loaded skill "${matched.name}" (${skill.instructions?.length ?? 0} chars)`);
|
|
612
|
-
return skill.instructions || null;
|
|
613
|
-
} catch (err) {
|
|
614
|
-
console.error(`[skill-router] Failed to load skill "${matched.name}":`, err);
|
|
615
|
-
return null;
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
|
|
619
598
|
private traceProcessRunning(procId: string): void {
|
|
620
599
|
if (!this.tracedRunning.has(procId)) {
|
|
621
600
|
this.tracedRunning.add(procId);
|
package/src/arc/arc-types.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface Episode {
|
|
|
18
18
|
success: boolean;
|
|
19
19
|
createdAt: number;
|
|
20
20
|
parentEpisodeIds: string[];
|
|
21
|
+
/** Typed output from generateObject when profile has a typed signature. */
|
|
22
|
+
structuredOutput?: Record<string, unknown>;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export interface EpisodeTrace {
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile builder — generates prompts and resolves tools from ProfileDeclarations.
|
|
3
|
+
*
|
|
4
|
+
* The core idea: profiles declare WHAT (signature, tools, background).
|
|
5
|
+
* This module generates the HOW (system prompts, tool objects).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AnyTool } from './arc-types';
|
|
9
|
+
import type { ProfileDeclaration, ProcessProfile, ProfileConfig } from './types';
|
|
10
|
+
import { isProfileDeclaration } from './types';
|
|
11
|
+
import { parseSignature, signatureToSchema, isTypedSignature } from './sig';
|
|
12
|
+
|
|
13
|
+
// ── Thread prompt generation ────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Generate a thread system prompt from a ProfileDeclaration.
|
|
17
|
+
*
|
|
18
|
+
* The prompt tells the thread:
|
|
19
|
+
* - What it is (name)
|
|
20
|
+
* - What it produces (signature)
|
|
21
|
+
* - What tools it has (enforced, not suggested)
|
|
22
|
+
* - Domain knowledge (background)
|
|
23
|
+
*/
|
|
24
|
+
export function buildProfilePrompt(decl: ProfileDeclaration): string {
|
|
25
|
+
const [input, output] = decl.signature.split('->').map(s => s.trim());
|
|
26
|
+
const lines = [
|
|
27
|
+
`You are a ${decl.name} thread.`,
|
|
28
|
+
`Your task: take ${input} and produce ${output}.`,
|
|
29
|
+
'',
|
|
30
|
+
`## Available tools`,
|
|
31
|
+
`You have access to: ${decl.tools.join(', ')}`,
|
|
32
|
+
'Use ONLY these tools. You cannot use any other tools.',
|
|
33
|
+
'If you have Skill Instructions below, follow them for delivery method and style.',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
if (decl.background) {
|
|
37
|
+
lines.push('', '## Background', decl.background);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return lines.join('\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Orchestrator prompt generation ──────────────────────────────
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Generate an orchestrator system prompt from a set of profile declarations.
|
|
47
|
+
*
|
|
48
|
+
* The orchestrator sees profile names, signatures, and tool counts —
|
|
49
|
+
* enough to route correctly. No tool names, no delivery methods.
|
|
50
|
+
*
|
|
51
|
+
* @param profiles - The registered profiles
|
|
52
|
+
* @param preamble - Optional strategy preamble (phasing, rules, context).
|
|
53
|
+
* Composed with the auto-generated profile catalog.
|
|
54
|
+
*/
|
|
55
|
+
export function buildOrchestratorPrompt(
|
|
56
|
+
profiles: Record<string, ProfileConfig>,
|
|
57
|
+
preamble?: string,
|
|
58
|
+
): string {
|
|
59
|
+
const profileList = Object.entries(profiles)
|
|
60
|
+
.map(([name, p]) => {
|
|
61
|
+
if (isProfileDeclaration(p)) {
|
|
62
|
+
return `- **${name}** — ${p.signature} (tools: ${p.tools.length}, ${p.model || 'medium'})`;
|
|
63
|
+
}
|
|
64
|
+
// Legacy ProcessProfile — show name only
|
|
65
|
+
return `- **${name}**`;
|
|
66
|
+
})
|
|
67
|
+
.join('\n');
|
|
68
|
+
|
|
69
|
+
const sections = [
|
|
70
|
+
'You are an orchestrator. Dispatch Thread calls to accomplish tasks.',
|
|
71
|
+
'',
|
|
72
|
+
'## Profiles',
|
|
73
|
+
profileList,
|
|
74
|
+
'',
|
|
75
|
+
'## Thread actions',
|
|
76
|
+
'Describe WHAT to create, not HOW. 2-5 sentences. Threads have tools and skill instructions — they know delivery methods.',
|
|
77
|
+
'ALWAYS set the profile parameter on every Thread call.',
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
if (preamble) {
|
|
81
|
+
// Insert preamble after the identity line
|
|
82
|
+
sections.splice(1, 0, '', preamble);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return sections.join('\n');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Tool resolution ─────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve tool names to tool objects from the global tool registry.
|
|
92
|
+
* Returns only the tools listed in the declaration.
|
|
93
|
+
*/
|
|
94
|
+
export function resolveProfileTools(
|
|
95
|
+
toolNames: string[],
|
|
96
|
+
globalTools: Record<string, AnyTool>,
|
|
97
|
+
): Record<string, AnyTool> {
|
|
98
|
+
const resolved: Record<string, AnyTool> = {};
|
|
99
|
+
for (const name of toolNames) {
|
|
100
|
+
if (globalTools[name]) {
|
|
101
|
+
resolved[name] = globalTools[name];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return resolved;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── ProfileDeclaration → ProcessProfile conversion ──────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build a runtime ProcessProfile from a ProfileDeclaration.
|
|
111
|
+
* Generates the system prompt and resolves tool objects.
|
|
112
|
+
*/
|
|
113
|
+
export function buildProcessProfile(
|
|
114
|
+
decl: ProfileDeclaration,
|
|
115
|
+
globalTools: Record<string, AnyTool>,
|
|
116
|
+
): ProcessProfile {
|
|
117
|
+
const profile: ProcessProfile = {
|
|
118
|
+
systemPrompt: buildProfilePrompt(decl),
|
|
119
|
+
tools: resolveProfileTools(decl.tools, globalTools),
|
|
120
|
+
allowedToolNames: decl.tools,
|
|
121
|
+
};
|
|
122
|
+
if (decl.model) profile.model = decl.model;
|
|
123
|
+
if (decl.maxSteps) profile.maxSteps = decl.maxSteps;
|
|
124
|
+
// Generate output schema from typed signatures (e.g., 'question:string -> evidence:string[]')
|
|
125
|
+
if (isTypedSignature(decl.signature)) {
|
|
126
|
+
const parsed = parseSignature(decl.signature);
|
|
127
|
+
if (parsed.outputs.length > 0) {
|
|
128
|
+
profile.outputSchema = signatureToSchema(parsed);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Render demos as user/assistant message pairs
|
|
132
|
+
if (decl.demos && decl.demos.length > 0) {
|
|
133
|
+
profile.demoMessages = decl.demos.flatMap(demo => [
|
|
134
|
+
{ role: 'user' as const, content: Object.entries(demo.input).map(([k, v]) => `${k}: ${v}`).join('\n') },
|
|
135
|
+
{ role: 'assistant' as const, content: JSON.stringify(demo.output) },
|
|
136
|
+
]);
|
|
137
|
+
}
|
|
138
|
+
return profile;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Resolve a ProfileConfig (declaration or legacy) into a ProcessProfile.
|
|
143
|
+
* Declarations are built; legacy profiles are passed through.
|
|
144
|
+
*/
|
|
145
|
+
export function resolveProfile(
|
|
146
|
+
config: ProfileConfig,
|
|
147
|
+
globalTools: Record<string, AnyTool>,
|
|
148
|
+
): ProcessProfile {
|
|
149
|
+
if (isProfileDeclaration(config)) {
|
|
150
|
+
return buildProcessProfile(config, globalTools);
|
|
151
|
+
}
|
|
152
|
+
// Backfill allowedToolNames for legacy profiles when tools are defined
|
|
153
|
+
if (config.tools && !config.allowedToolNames) {
|
|
154
|
+
return { ...config, allowedToolNames: Object.keys(config.tools) };
|
|
155
|
+
}
|
|
156
|
+
return config;
|
|
157
|
+
}
|
package/src/arc/sig.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSPy-style signature parsing and schema generation.
|
|
3
|
+
*
|
|
4
|
+
* Parses signature strings like "question:string -> evidence:string[], confidence:number"
|
|
5
|
+
* into typed field definitions, and generates Zod schemas for structured output.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
|
|
10
|
+
// ── Types ──
|
|
11
|
+
|
|
12
|
+
export interface SignatureField {
|
|
13
|
+
name: string;
|
|
14
|
+
type: 'string' | 'number' | 'boolean';
|
|
15
|
+
isArray: boolean;
|
|
16
|
+
isOptional: boolean;
|
|
17
|
+
description?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ParsedSignature {
|
|
21
|
+
inputs: SignatureField[];
|
|
22
|
+
outputs: SignatureField[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── Parser ──
|
|
26
|
+
|
|
27
|
+
const FIELD_RE = /^(\w+)(?::(\w+)(\[\])?)(\?)?(?:\s*\(([^)]+)\))?$/;
|
|
28
|
+
|
|
29
|
+
function parseField(raw: string): SignatureField {
|
|
30
|
+
const trimmed = raw.trim();
|
|
31
|
+
const match = trimmed.match(FIELD_RE);
|
|
32
|
+
|
|
33
|
+
if (!match) {
|
|
34
|
+
// Bare field name — default to string
|
|
35
|
+
const name = trimmed.replace(/\?$/, '');
|
|
36
|
+
return {
|
|
37
|
+
name,
|
|
38
|
+
type: 'string',
|
|
39
|
+
isArray: false,
|
|
40
|
+
isOptional: trimmed.endsWith('?'),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const [, name, typeStr, arrayMark, optionalMark, desc] = match;
|
|
45
|
+
const type = (['string', 'number', 'boolean'].includes(typeStr!) ? typeStr : 'string') as SignatureField['type'];
|
|
46
|
+
|
|
47
|
+
const field: SignatureField = {
|
|
48
|
+
name: name!,
|
|
49
|
+
type,
|
|
50
|
+
isArray: arrayMark === '[]',
|
|
51
|
+
isOptional: optionalMark === '?',
|
|
52
|
+
};
|
|
53
|
+
if (desc) field.description = desc.trim();
|
|
54
|
+
return field;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse a signature string into typed input/output fields.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* parseSignature('question:string -> evidence:string[], confidence:number')
|
|
62
|
+
* // { inputs: [{ name: 'question', type: 'string', ... }],
|
|
63
|
+
* // outputs: [{ name: 'evidence', type: 'string', isArray: true, ... },
|
|
64
|
+
* // { name: 'confidence', type: 'number', ... }] }
|
|
65
|
+
*
|
|
66
|
+
* parseSignature('query -> findings')
|
|
67
|
+
* // All fields default to type 'string', isArray false
|
|
68
|
+
*/
|
|
69
|
+
export function parseSignature(sig: string): ParsedSignature {
|
|
70
|
+
const arrowIdx = sig.indexOf('->');
|
|
71
|
+
if (arrowIdx < 0) {
|
|
72
|
+
// No arrow — treat entire string as a single input, single output
|
|
73
|
+
const parts = sig.split(',').map(s => s.trim()).filter(Boolean);
|
|
74
|
+
return {
|
|
75
|
+
inputs: parts.length > 0 ? [parseField(parts[0]!)] : [],
|
|
76
|
+
outputs: parts.length > 1 ? [parseField(parts[1]!)] : [{ name: 'result', type: 'string', isArray: false, isOptional: false }],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const inputStr = sig.slice(0, arrowIdx).trim();
|
|
81
|
+
const outputStr = sig.slice(arrowIdx + 2).trim();
|
|
82
|
+
|
|
83
|
+
const inputs = inputStr.split(',').map(s => s.trim()).filter(Boolean).map(parseField);
|
|
84
|
+
const outputs = outputStr.split(',').map(s => s.trim()).filter(Boolean).map(parseField);
|
|
85
|
+
|
|
86
|
+
return { inputs, outputs };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Schema generation ──
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Convert a parsed signature's output fields into a Zod schema.
|
|
93
|
+
* Used with ai-sdk's generateObject() for structured output extraction.
|
|
94
|
+
*/
|
|
95
|
+
export function signatureToSchema(sig: ParsedSignature): z.ZodObject<Record<string, z.ZodTypeAny>> {
|
|
96
|
+
const shape: Record<string, z.ZodTypeAny> = {};
|
|
97
|
+
|
|
98
|
+
for (const field of sig.outputs) {
|
|
99
|
+
let base: z.ZodTypeAny;
|
|
100
|
+
switch (field.type) {
|
|
101
|
+
case 'number': base = z.number(); break;
|
|
102
|
+
case 'boolean': base = z.boolean(); break;
|
|
103
|
+
default: base = z.string(); break;
|
|
104
|
+
}
|
|
105
|
+
if (field.isArray) base = z.array(base);
|
|
106
|
+
if (field.description) base = base.describe(field.description);
|
|
107
|
+
if (field.isOptional) base = base.optional();
|
|
108
|
+
shape[field.name] = base;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return z.object(shape);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Check if a signature string has typed fields (contains ':').
|
|
116
|
+
* Untyped signatures like 'query -> findings' are treated as cosmetic labels.
|
|
117
|
+
*/
|
|
118
|
+
export function isTypedSignature(sig: string): boolean {
|
|
119
|
+
return sig.includes(':');
|
|
120
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SkillResolver — pluggable interface for resolving skill instructions from action text.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from ArcLoop to satisfy DIP + SRP:
|
|
5
|
+
* - ArcLoop depends on the interface, not on SkillRouter/file loader directly
|
|
6
|
+
* - Tests can mock skill resolution without filesystem
|
|
7
|
+
* - Alternative implementations (remote registries) plug in without touching ArcLoop
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { SkillRouter } from '../skills/skill-router';
|
|
11
|
+
import { loadSkillFromFile } from '../skills/skill-loader';
|
|
12
|
+
import type { SkillSummary } from '../skills/skill-types';
|
|
13
|
+
|
|
14
|
+
// ── Interface ──
|
|
15
|
+
|
|
16
|
+
export interface ResolvedSkill {
|
|
17
|
+
name: string;
|
|
18
|
+
systemPrompt: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SkillResolver {
|
|
22
|
+
resolve(action: string, profileSkills?: string[]): Promise<ResolvedSkill | null>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── Default implementation ──
|
|
26
|
+
|
|
27
|
+
export interface DefaultSkillResolverConfig {
|
|
28
|
+
/** Path to skills-index.json */
|
|
29
|
+
skillIndexPath: string;
|
|
30
|
+
/** Optional SkillRouter config (aliases, model, etc.) */
|
|
31
|
+
routerConfig?: ConstructorParameters<typeof SkillRouter>[0];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class DefaultSkillResolver implements SkillResolver {
|
|
35
|
+
private readonly router: SkillRouter;
|
|
36
|
+
private readonly summariesPromise: Promise<SkillSummary[]>;
|
|
37
|
+
private summaries: SkillSummary[] | null = null;
|
|
38
|
+
|
|
39
|
+
constructor(config: DefaultSkillResolverConfig) {
|
|
40
|
+
this.router = new SkillRouter(config.routerConfig);
|
|
41
|
+
this.summariesPromise = import('node:fs/promises')
|
|
42
|
+
.then(fs => fs.readFile(config.skillIndexPath, 'utf-8'))
|
|
43
|
+
.then(raw => JSON.parse(raw) as SkillSummary[])
|
|
44
|
+
.catch(() => []);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async resolve(action: string, profileSkills?: string[]): Promise<ResolvedSkill | null> {
|
|
48
|
+
if (!this.summaries) {
|
|
49
|
+
this.summaries = await this.summariesPromise;
|
|
50
|
+
}
|
|
51
|
+
if (this.summaries.length === 0) return null;
|
|
52
|
+
|
|
53
|
+
// If profile restricts skills, filter summaries to that set
|
|
54
|
+
const candidates = profileSkills
|
|
55
|
+
? this.summaries.filter(s => profileSkills.includes(s.name))
|
|
56
|
+
: this.summaries;
|
|
57
|
+
|
|
58
|
+
if (candidates.length === 0) return null;
|
|
59
|
+
|
|
60
|
+
const matched = await this.router.selectSkill(action, candidates);
|
|
61
|
+
if (matched) {
|
|
62
|
+
console.log(`[skill-resolver] Matched skill "${matched.name}" for action: "${action.slice(0, 80)}..."`);
|
|
63
|
+
} else {
|
|
64
|
+
console.log(`[skill-resolver] No match for action: "${action.slice(0, 80)}..."`);
|
|
65
|
+
}
|
|
66
|
+
if (!matched) return null;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const skill = await loadSkillFromFile(matched.path);
|
|
70
|
+
console.log(`[skill-resolver] Loaded skill "${matched.name}" (${skill.instructions?.length ?? 0} chars)`);
|
|
71
|
+
if (!skill.instructions) return null;
|
|
72
|
+
return { name: matched.name, systemPrompt: skill.instructions };
|
|
73
|
+
} catch (err) {
|
|
74
|
+
console.error(`[skill-resolver] Failed to load skill "${matched.name}":`, err);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -22,6 +22,7 @@ const episodeSchema: RxJsonSchema<Episode> = {
|
|
|
22
22
|
success: { type: 'boolean' },
|
|
23
23
|
createdAt: { type: 'integer' },
|
|
24
24
|
parentEpisodeIds: { type: 'array', items: { type: 'string' } },
|
|
25
|
+
structuredOutput: { type: 'object' },
|
|
25
26
|
},
|
|
26
27
|
required: ['id', 'taskId', 'sessionId', 'index', 'threadAction', 'summary', 'model', 'steps', 'success', 'createdAt'],
|
|
27
28
|
indexes: ['taskId', 'sessionId', 'createdAt'],
|
package/src/arc/types.ts
CHANGED
|
@@ -41,14 +41,13 @@ export interface EpisodeArtifact {
|
|
|
41
41
|
// ── ArcLoop v2 Config ──
|
|
42
42
|
|
|
43
43
|
export interface ArcLoopConfig {
|
|
44
|
+
// Orchestrator
|
|
44
45
|
/** Orchestrator model (default: 'claude-opus-4-6'). Accepts a model ID or tier name. */
|
|
45
46
|
model?: string;
|
|
46
47
|
/** Model tier mapping. Override to use different models for fast/medium/strong. */
|
|
47
48
|
modelMap?: Record<import('./arc-types').ModelTier, string>;
|
|
48
49
|
/** Anthropic API key */
|
|
49
50
|
apiKey?: string;
|
|
50
|
-
|
|
51
|
-
// Orchestrator
|
|
52
51
|
/** Custom orchestrator system prompt */
|
|
53
52
|
systemPrompt?: string;
|
|
54
53
|
/** Max orchestrator turns before stopping (default: 30) */
|
|
@@ -57,6 +56,8 @@ export interface ArcLoopConfig {
|
|
|
57
56
|
extraOrchestratorTools?: Record<string, import('./arc-types').AnyTool>;
|
|
58
57
|
/** Handler for extra orchestrator tools. Return an AgentAction (typically FinalAction with directive). */
|
|
59
58
|
onOrchestratorTool?: (name: string, args: Record<string, unknown>) => Promise<import('../agent/types').AgentAction>;
|
|
59
|
+
/** Optional resilience policy applied to LLM calls (retry, circuit breaker, timeout, etc.). */
|
|
60
|
+
resilience?: import('./resilience/types').ResiliencePolicy;
|
|
60
61
|
|
|
61
62
|
// Processes
|
|
62
63
|
/** Per-process timeout in ms (default: 120_000) */
|
|
@@ -65,8 +66,10 @@ export interface ArcLoopConfig {
|
|
|
65
66
|
processMaxSteps?: number;
|
|
66
67
|
/** Default system prompt for all processes (overrides the built-in default) */
|
|
67
68
|
processSystemPrompt?: string;
|
|
68
|
-
/** Named process profiles.
|
|
69
|
-
processProfiles?: Record<string,
|
|
69
|
+
/** Named process profiles. Accepts ProfileDeclaration (new, declarative) or ProcessProfile (legacy). */
|
|
70
|
+
processProfiles?: Record<string, ProfileConfig>;
|
|
71
|
+
/** Tools available inside processes (default: builtinTools) */
|
|
72
|
+
processTools?: Record<string, import('./arc-types').AnyTool>;
|
|
70
73
|
|
|
71
74
|
// Context
|
|
72
75
|
/** Context window size in tokens (default: 200_000) */
|
|
@@ -74,10 +77,6 @@ export interface ArcLoopConfig {
|
|
|
74
77
|
/** Tokens reserved for output (default: 20_000) */
|
|
75
78
|
outputReserve?: number;
|
|
76
79
|
|
|
77
|
-
// Memory
|
|
78
|
-
/** Enable auto-memory detection and promotion (default: true) */
|
|
79
|
-
autoMemory?: boolean;
|
|
80
|
-
|
|
81
80
|
// Stores (required)
|
|
82
81
|
episodeStore: import('./arc-types').EpisodeStore;
|
|
83
82
|
sessionMemoStore: import('./arc-types').SessionMemoStore;
|
|
@@ -87,10 +86,16 @@ export interface ArcLoopConfig {
|
|
|
87
86
|
taskId: string;
|
|
88
87
|
sessionId: string;
|
|
89
88
|
|
|
89
|
+
// Memory
|
|
90
|
+
/** Enable auto-memory detection and promotion (default: true) */
|
|
91
|
+
autoMemory?: boolean;
|
|
92
|
+
|
|
93
|
+
// Dependency injection
|
|
94
|
+
/** Pre-built SkillResolver instance. If omitted, one is created from skillIndexPath (if provided). */
|
|
95
|
+
skillResolver?: import('./skill-resolver').SkillResolver;
|
|
96
|
+
|
|
90
97
|
// Tools & providers
|
|
91
98
|
toolProvider: import('../interfaces/tool-provider').ToolProvider;
|
|
92
|
-
/** Tools available inside processes (default: builtinTools) */
|
|
93
|
-
processTools?: Record<string, import('./arc-types').AnyTool>;
|
|
94
99
|
/** Tool provider for skill-matched processes (sandbox) */
|
|
95
100
|
skillToolProvider?: import('../interfaces/tool-provider').ToolProvider;
|
|
96
101
|
/** Local directory to sync sandbox output artifacts to (default: './outputs') */
|
|
@@ -104,9 +109,6 @@ export interface ArcLoopConfig {
|
|
|
104
109
|
hookRunner?: import('../hooks/hook-runner').HookRunner;
|
|
105
110
|
permissionManager?: import('../permissions/permission-manager').PermissionManager;
|
|
106
111
|
executeToolAction?: (action: import('../agent/types').ToolCallAction) => Promise<import('../interfaces/tool-provider').ToolResult | null>;
|
|
107
|
-
|
|
108
|
-
/** Optional resilience policy applied to LLM calls (retry, circuit breaker, timeout, etc.). */
|
|
109
|
-
resilience?: import('./resilience/types').ResiliencePolicy;
|
|
110
112
|
}
|
|
111
113
|
|
|
112
114
|
// ── Process types ──
|
|
@@ -156,7 +158,43 @@ export interface ProcessRequest {
|
|
|
156
158
|
profile?: string;
|
|
157
159
|
}
|
|
158
160
|
|
|
159
|
-
/**
|
|
161
|
+
/**
|
|
162
|
+
* ProfileDeclaration — typed, declarative profile definition.
|
|
163
|
+
*
|
|
164
|
+
* Replaces hand-written system prompts with structured declarations.
|
|
165
|
+
* The system generates prompts from the declaration and enforces tool constraints.
|
|
166
|
+
*
|
|
167
|
+
* @example
|
|
168
|
+
* const visual: ProfileDeclaration = {
|
|
169
|
+
* name: 'visual',
|
|
170
|
+
* signature: 'description -> compiledArtifact',
|
|
171
|
+
* tools: ['CompileJsx', 'Read', 'ListFiles'],
|
|
172
|
+
* skills: ['visual-explainer'],
|
|
173
|
+
* };
|
|
174
|
+
*/
|
|
175
|
+
export interface ProfileDeclaration {
|
|
176
|
+
/** Profile name (matches the Thread tool's profile parameter). */
|
|
177
|
+
name: string;
|
|
178
|
+
/** Typed signature: "input -> output". Describes what this profile produces. */
|
|
179
|
+
signature: string;
|
|
180
|
+
/** Tool names this profile can use. Only these tools are available — enforced at schema + executor level. */
|
|
181
|
+
tools: string[];
|
|
182
|
+
/** Domain knowledge injected into system prompt (skill content, color systems, etc.). */
|
|
183
|
+
background?: string;
|
|
184
|
+
/** Default model tier. */
|
|
185
|
+
model?: import('./arc-types').ModelTier;
|
|
186
|
+
/** Max LLM steps. */
|
|
187
|
+
maxSteps?: number;
|
|
188
|
+
/** Skill names this profile can use. SkillRouter is constrained to this set. Omit for all skills. */
|
|
189
|
+
skills?: string[];
|
|
190
|
+
/** Few-shot demonstration examples. Rendered as user/assistant message pairs in the prompt. */
|
|
191
|
+
demos?: Array<{
|
|
192
|
+
input: Record<string, string>;
|
|
193
|
+
output: Record<string, unknown>;
|
|
194
|
+
}>;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A named process profile — runtime form with resolved prompt and tools. */
|
|
160
198
|
export interface ProcessProfile {
|
|
161
199
|
/** System prompt for processes using this profile. */
|
|
162
200
|
systemPrompt: string;
|
|
@@ -166,6 +204,21 @@ export interface ProcessProfile {
|
|
|
166
204
|
model?: import('./arc-types').ModelTier;
|
|
167
205
|
/** Max steps for this profile (Thread tool's explicit maxSteps overrides this). */
|
|
168
206
|
maxSteps?: number;
|
|
207
|
+
/** Allowed tool names for executor-level validation (populated from ProfileDeclaration.tools). */
|
|
208
|
+
allowedToolNames?: string[];
|
|
209
|
+
/** Zod schema for structured output on the terminal step (generated from typed signatures). */
|
|
210
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
211
|
+
outputSchema?: import('zod').ZodObject<any>;
|
|
212
|
+
/** Few-shot demo messages rendered before the task prompt. */
|
|
213
|
+
demoMessages?: import('../agent/types').AgentMessage[];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** A profile config can be either a declaration (new) or a raw ProcessProfile (backward compat). */
|
|
217
|
+
export type ProfileConfig = ProfileDeclaration | ProcessProfile;
|
|
218
|
+
|
|
219
|
+
/** Type guard: check if a profile config is a declaration. */
|
|
220
|
+
export function isProfileDeclaration(p: ProfileConfig): p is ProfileDeclaration {
|
|
221
|
+
return 'signature' in p && typeof (p as ProfileDeclaration).signature === 'string';
|
|
169
222
|
}
|
|
170
223
|
|
|
171
224
|
export type Activity =
|