@bluecopa/harness 0.1.0-snapshot.33 → 0.1.0-snapshot.34

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.34",
4
4
  "description": "Provider-agnostic TypeScript agent framework",
5
5
  "license": "UNLICENSED",
6
6
  "scripts": {
@@ -193,6 +193,8 @@ 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[];
196
198
 
197
199
  // Runtime extras
198
200
  hookRunner?: HookRunner;
@@ -293,6 +295,22 @@ export class AgentRunner {
293
295
  toolCallId: tc.toolCallId,
294
296
  };
295
297
 
298
+ // Layer 2: executor-level tool validation (defense-in-depth)
299
+ if (config.allowedToolNames && !config.allowedToolNames.includes(tc.toolName)) {
300
+ const resultText = `ERROR: Tool "${tc.toolName}" is not available in this profile.`;
301
+ messages.push({
302
+ role: 'tool',
303
+ content: resultText,
304
+ toolResults: [{
305
+ toolCallId: tc.toolCallId,
306
+ toolName: tc.toolName,
307
+ result: resultText,
308
+ isError: true,
309
+ }],
310
+ });
311
+ continue;
312
+ }
313
+
296
314
  config.onActivity?.({
297
315
  type: 'tool_start',
298
316
  name: tc.toolName,
@@ -368,6 +386,8 @@ export interface CreateProcessConfig {
368
386
  processSystemPrompt?: string;
369
387
  /** Async skill instructions to prepend to system prompt (resolved during process startup). */
370
388
  skillPromptPromise?: Promise<string | null>;
389
+ /** Allowed tool names for executor-level validation (defense-in-depth against hallucinated tool calls). */
390
+ allowedToolNames?: string[];
371
391
 
372
392
  // Runtime extras
373
393
  hookRunner?: HookRunner;
@@ -454,6 +474,7 @@ export function createProcess(
454
474
  'askUser',
455
475
  'tellUser',
456
476
  'downloadRawFile',
477
+ 'allowedToolNames',
457
478
  ]),
458
479
  }),
459
480
  timeoutPromise(config.processTimeout),
@@ -462,7 +483,7 @@ export function createProcess(
462
483
  const durationMs = Date.now() - startTime;
463
484
  const nextIndex = await getNextEpisodeIndex(config.episodeStore, config.taskId);
464
485
 
465
- const { episode, trace, artifacts } = compressor.compress({
486
+ const compressInput = {
466
487
  taskId: config.taskId,
467
488
  sessionId: config.sessionId,
468
489
  index: nextIndex,
@@ -471,7 +492,9 @@ export function createProcess(
471
492
  model,
472
493
  parentEpisodeIds: request.contextEpisodeIds ?? [],
473
494
  success: true,
474
- });
495
+ };
496
+
497
+ const { episode, trace, artifacts } = compressor.compress(compressInput);
475
498
 
476
499
  await config.episodeStore.addEpisode(episode);
477
500
  await config.episodeStore.addTrace(trace);
@@ -490,18 +513,10 @@ export function createProcess(
490
513
  outbox.push({ type: 'episode', episode });
491
514
  outbox.push({ type: 'done', result: processResult });
492
515
 
516
+ // Async LLM upgrade for long episodes
493
517
  if (episode.steps > 10) {
494
518
  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) => {
519
+ void compressor.compressLLM(compressInput, upgradeAc.signal).then(async (upgraded) => {
505
520
  episode.summary = upgraded.episode.summary;
506
521
  await config.episodeStore.addEpisode(episode);
507
522
  }).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,9 @@ 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,
575
581
  skillPromptPromise,
576
582
  parentSignal,
577
583
  ...pickDefined(this.config, [
@@ -587,35 +593,6 @@ export class ArcLoop {
587
593
  return proc;
588
594
  }
589
595
 
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
596
  private traceProcessRunning(procId: string): void {
620
597
  if (!this.tracedRunning.has(procId)) {
621
598
  this.tracedRunning.add(procId);
@@ -0,0 +1,142 @@
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
+
12
+ // ── Thread prompt generation ────────────────────────────────────
13
+
14
+ /**
15
+ * Generate a thread system prompt from a ProfileDeclaration.
16
+ *
17
+ * The prompt tells the thread:
18
+ * - What it is (name)
19
+ * - What it produces (signature)
20
+ * - What tools it has (enforced, not suggested)
21
+ * - Domain knowledge (background)
22
+ */
23
+ export function buildProfilePrompt(decl: ProfileDeclaration): string {
24
+ const [input, output] = decl.signature.split('->').map(s => s.trim());
25
+ const lines = [
26
+ `You are a ${decl.name} thread.`,
27
+ `Your task: take ${input} and produce ${output}.`,
28
+ '',
29
+ `## Available tools`,
30
+ `You have access to: ${decl.tools.join(', ')}`,
31
+ 'Use ONLY these tools. You cannot use any other tools.',
32
+ 'If you have Skill Instructions below, follow them for delivery method and style.',
33
+ ];
34
+
35
+ if (decl.background) {
36
+ lines.push('', '## Background', decl.background);
37
+ }
38
+
39
+ return lines.join('\n');
40
+ }
41
+
42
+ // ── Orchestrator prompt generation ──────────────────────────────
43
+
44
+ /**
45
+ * Generate an orchestrator system prompt from a set of profile declarations.
46
+ *
47
+ * The orchestrator sees profile names, signatures, and tool counts —
48
+ * enough to route correctly. No tool names, no delivery methods.
49
+ *
50
+ * @param profiles - The registered profiles
51
+ * @param preamble - Optional strategy preamble (phasing, rules, context).
52
+ * Composed with the auto-generated profile catalog.
53
+ */
54
+ export function buildOrchestratorPrompt(
55
+ profiles: Record<string, ProfileConfig>,
56
+ preamble?: string,
57
+ ): string {
58
+ const profileList = Object.entries(profiles)
59
+ .map(([name, p]) => {
60
+ if (isProfileDeclaration(p)) {
61
+ return `- **${name}** — ${p.signature} (tools: ${p.tools.length}, ${p.model || 'medium'})`;
62
+ }
63
+ // Legacy ProcessProfile — show name only
64
+ return `- **${name}**`;
65
+ })
66
+ .join('\n');
67
+
68
+ const sections = [
69
+ 'You are an orchestrator. Dispatch Thread calls to accomplish tasks.',
70
+ '',
71
+ '## Profiles',
72
+ profileList,
73
+ '',
74
+ '## Thread actions',
75
+ 'Describe WHAT to create, not HOW. 2-5 sentences. Threads have tools and skill instructions — they know delivery methods.',
76
+ 'ALWAYS set the profile parameter on every Thread call.',
77
+ ];
78
+
79
+ if (preamble) {
80
+ // Insert preamble after the identity line
81
+ sections.splice(1, 0, '', preamble);
82
+ }
83
+
84
+ return sections.join('\n');
85
+ }
86
+
87
+ // ── Tool resolution ─────────────────────────────────────────────
88
+
89
+ /**
90
+ * Resolve tool names to tool objects from the global tool registry.
91
+ * Returns only the tools listed in the declaration.
92
+ */
93
+ export function resolveProfileTools(
94
+ toolNames: string[],
95
+ globalTools: Record<string, AnyTool>,
96
+ ): Record<string, AnyTool> {
97
+ const resolved: Record<string, AnyTool> = {};
98
+ for (const name of toolNames) {
99
+ if (globalTools[name]) {
100
+ resolved[name] = globalTools[name];
101
+ }
102
+ }
103
+ return resolved;
104
+ }
105
+
106
+ // ── ProfileDeclaration → ProcessProfile conversion ──────────────
107
+
108
+ /**
109
+ * Build a runtime ProcessProfile from a ProfileDeclaration.
110
+ * Generates the system prompt and resolves tool objects.
111
+ */
112
+ export function buildProcessProfile(
113
+ decl: ProfileDeclaration,
114
+ globalTools: Record<string, AnyTool>,
115
+ ): ProcessProfile {
116
+ const profile: ProcessProfile = {
117
+ systemPrompt: buildProfilePrompt(decl),
118
+ tools: resolveProfileTools(decl.tools, globalTools),
119
+ allowedToolNames: decl.tools,
120
+ };
121
+ if (decl.model) profile.model = decl.model;
122
+ if (decl.maxSteps) profile.maxSteps = decl.maxSteps;
123
+ return profile;
124
+ }
125
+
126
+ /**
127
+ * Resolve a ProfileConfig (declaration or legacy) into a ProcessProfile.
128
+ * Declarations are built; legacy profiles are passed through.
129
+ */
130
+ export function resolveProfile(
131
+ config: ProfileConfig,
132
+ globalTools: Record<string, AnyTool>,
133
+ ): ProcessProfile {
134
+ if (isProfileDeclaration(config)) {
135
+ return buildProcessProfile(config, globalTools);
136
+ }
137
+ // Backfill allowedToolNames for legacy profiles when tools are defined
138
+ if (config.tools && !config.allowedToolNames) {
139
+ return { ...config, allowedToolNames: Object.keys(config.tools) };
140
+ }
141
+ return config;
142
+ }
@@ -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
+ }
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,38 @@ 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
+ }
191
+
192
+ /** A named process profile — runtime form with resolved prompt and tools. */
160
193
  export interface ProcessProfile {
161
194
  /** System prompt for processes using this profile. */
162
195
  systemPrompt: string;
@@ -166,6 +199,16 @@ export interface ProcessProfile {
166
199
  model?: import('./arc-types').ModelTier;
167
200
  /** Max steps for this profile (Thread tool's explicit maxSteps overrides this). */
168
201
  maxSteps?: number;
202
+ /** Allowed tool names for executor-level validation (populated from ProfileDeclaration.tools). */
203
+ allowedToolNames?: string[];
204
+ }
205
+
206
+ /** A profile config can be either a declaration (new) or a raw ProcessProfile (backward compat). */
207
+ export type ProfileConfig = ProfileDeclaration | ProcessProfile;
208
+
209
+ /** Type guard: check if a profile config is a declaration. */
210
+ export function isProfileDeclaration(p: ProfileConfig): p is ProfileDeclaration {
211
+ return 'signature' in p && typeof (p as ProfileDeclaration).signature === 'string';
169
212
  }
170
213
 
171
214
  export type Activity =