@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bluecopa/harness",
3
- "version": "0.1.0-snapshot.33",
3
+ "version": "0.1.0-snapshot.35",
4
4
  "description": "Provider-agnostic TypeScript agent framework",
5
5
  "license": "UNLICENSED",
6
6
  "scripts": {
@@ -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 = await seedPromise;
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 { episode, trace, artifacts } = compressor.compress({
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 */ });
@@ -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 { SkillRouter } from '../skills/skill-router';
31
- import { loadSkillFromFile } from '../skills/skill-loader';
32
- import type { SkillSummary } from '../skills/skill-types';
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 skillRouter: SkillRouter | undefined;
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
- if (config.skillIndexPath) {
128
- this.skillRouter = new SkillRouter();
129
- // Lazy-load skill summaries on first dispatch
130
- this.skillSummariesPromise = import('node:fs/promises')
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 profile = request.profile
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 only when skills are configured
559
- const skillPromptPromise = this.skillRouter
560
- ? this.resolveSkillPrompt(request.action)
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 ?? this.config.processTools ?? builtinTools) as any,
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);
@@ -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. The orchestrator selects a profile via the Thread tool's `profile` param. */
69
- processProfiles?: Record<string, ProcessProfile>;
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
- /** A named process profile — provides defaults for system prompt, tools, model, and step limit. */
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 =