diff --git a/packages/kernel/src/__snapshots__/schema-drift-regression.test.ts.snap b/packages/kernel/src/__snapshots__/schema-drift-regression.test.ts.snap index 8a869fb..2591a3c 100644 --- a/packages/kernel/src/__snapshots__/schema-drift-regression.test.ts.snap +++ b/packages/kernel/src/__snapshots__/schema-drift-regression.test.ts.snap @@ -123,6 +123,361 @@ exports[`schema drift regression > locks CLI option signatures for critical comm exports[`schema drift regression > locks MCP tool metadata and input schemas 1`] = ` [ + { + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + }, + "description": "Post a correlated question and optionally await/poll for a reply.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "actor": { + "description": "Actor identity for write attribution.", + "type": "string", + }, + "awaitReply": { + "description": "Whether the tool should wait for a matching reply event.", + "type": "boolean", + }, + "conversationPath": { + "description": "Optional existing conversation path.", + "type": "string", + }, + "correlationId": { + "description": "Optional correlation ID (generated when omitted).", + "type": "string", + }, + "evidence": { + "description": "Optional evidence attachments for ask context.", + "items": { + "properties": { + "kind": { + "description": "Evidence attachment kind.", + "enum": [ + "link", + "file", + ], + "type": "string", + }, + "mime_type": { + "description": "MIME type for this attachment when known.", + "type": "string", + }, + "path": { + "description": "Workspace-relative file path for file evidence.", + "type": "string", + }, + "sha256": { + "description": "Optional sha256 digest for file integrity checks.", + "type": "string", + }, + "size_bytes": { + "description": "Attachment size in bytes.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "title": { + "description": "Short human-readable evidence title.", + "type": "string", + }, + "url": { + "description": "Evidence URL for link-based artifacts.", + "type": "string", + }, + }, + "required": [ + "kind", + ], + "type": "object", + }, + "type": "array", + }, + "idempotencyKey": { + "description": "Stable idempotency key for retry-safe asks.", + "type": "string", + }, + "metadata": { + "additionalProperties": {}, + "description": "Arbitrary machine-readable metadata preserved with the event.", + "propertyNames": { + "type": "string", + }, + "type": "object", + }, + "pollIntervalMs": { + "description": "Polling interval used while awaiting reply.", + "maximum": 5000, + "minimum": 25, + "type": "integer", + }, + "question": { + "description": "Question text to post.", + "minLength": 1, + "type": "string", + }, + "threadPath": { + "description": "Target thread path (threads/.md).", + "minLength": 1, + "type": "string", + }, + "timeoutMs": { + "description": "Reply wait timeout when awaitReply=true.", + "maximum": 120000, + "minimum": 0, + "type": "integer", + }, + }, + "required": [ + "threadPath", + "question", + ], + "type": "object", + }, + "name": "wg_ask", + "title": "WorkGraph Ask", + }, + { + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + }, + "description": "Write agent liveness plus active-work claim heartbeat updates.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "actor": { + "description": "Actor identity to heartbeat.", + "type": "string", + }, + "capabilities": { + "description": "Optional runtime capabilities snapshot for presence.", + "items": { + "type": "string", + }, + "type": "array", + }, + "currentWork": { + "description": "Current work/thread marker for presence state.", + "type": "string", + }, + "status": { + "description": "Presence status update for actor liveness.", + "enum": [ + "online", + "busy", + "offline", + ], + "type": "string", + }, + "threadLeaseMinutes": { + "description": "Thread lease extension window in minutes.", + "maximum": 240, + "minimum": 1, + "type": "integer", + }, + "threadPath": { + "description": "Optional specific thread to heartbeat claim lease for.", + "type": "string", + }, + }, + "type": "object", + }, + "name": "wg_heartbeat", + "title": "WorkGraph Heartbeat", + }, + { + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + }, + "description": "Append a structured collaboration message event to a thread conversation.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "actor": { + "description": "Actor identity for write attribution.", + "type": "string", + }, + "body": { + "description": "Message body text to append.", + "minLength": 1, + "type": "string", + }, + "conversationPath": { + "description": "Optional existing conversation path.", + "type": "string", + }, + "correlationId": { + "description": "Correlation ID for ask/reply coordination.", + "type": "string", + }, + "evidence": { + "description": "Optional evidence attachment descriptors.", + "items": { + "properties": { + "kind": { + "description": "Evidence attachment kind.", + "enum": [ + "link", + "file", + ], + "type": "string", + }, + "mime_type": { + "description": "MIME type for this attachment when known.", + "type": "string", + }, + "path": { + "description": "Workspace-relative file path for file evidence.", + "type": "string", + }, + "sha256": { + "description": "Optional sha256 digest for file integrity checks.", + "type": "string", + }, + "size_bytes": { + "description": "Attachment size in bytes.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "title": { + "description": "Short human-readable evidence title.", + "type": "string", + }, + "url": { + "description": "Evidence URL for link-based artifacts.", + "type": "string", + }, + }, + "required": [ + "kind", + ], + "type": "object", + }, + "type": "array", + }, + "idempotencyKey": { + "description": "Stable idempotency key for retry-safe writes.", + "type": "string", + }, + "messageType": { + "description": "Conversation event type/kind.", + "enum": [ + "message", + "note", + "decision", + "system", + "ask", + "reply", + ], + "type": "string", + }, + "metadata": { + "additionalProperties": {}, + "description": "Arbitrary machine-readable metadata preserved with the event.", + "propertyNames": { + "type": "string", + }, + "type": "object", + }, + "replyToCorrelationId": { + "description": "Correlation ID this reply responds to.", + "type": "string", + }, + "threadPath": { + "description": "Target thread path (threads/.md).", + "minLength": 1, + "type": "string", + }, + }, + "required": [ + "threadPath", + "body", + ], + "type": "object", + }, + "name": "wg_post_message", + "title": "WorkGraph Post Message", + }, + { + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + }, + "description": "Create a child thread with inherited context and optional idempotency key.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "actor": { + "description": "Actor identity for write attribution.", + "type": "string", + }, + "contextRefs": { + "description": "Additional context refs inherited by child thread.", + "items": { + "type": "string", + }, + "type": "array", + }, + "conversationPath": { + "description": "Optional conversation to attach spawned child thread.", + "type": "string", + }, + "deps": { + "description": "Optional dependency thread refs.", + "items": { + "type": "string", + }, + "type": "array", + }, + "goal": { + "description": "New child thread goal/body seed.", + "minLength": 1, + "type": "string", + }, + "idempotencyKey": { + "description": "Stable idempotency key for retry-safe spawn.", + "type": "string", + }, + "parentThreadPath": { + "description": "Parent thread path for child spawn operation.", + "minLength": 1, + "type": "string", + }, + "priority": { + "description": "Optional child priority override.", + "type": "string", + }, + "space": { + "description": "Optional space override for the spawned child thread.", + "type": "string", + }, + "tags": { + "description": "Optional child tags.", + "items": { + "type": "string", + }, + "type": "array", + }, + "title": { + "description": "New child thread title.", + "minLength": 1, + "type": "string", + }, + }, + "required": [ + "parentThreadPath", + "title", + "goal", + ], + "type": "object", + }, + "name": "wg_spawn_thread", + "title": "WorkGraph Spawn Thread", + }, { "annotations": { "destructiveHint": true, diff --git a/packages/kernel/src/agent-self-assembly.test.ts b/packages/kernel/src/agent-self-assembly.test.ts new file mode 100644 index 0000000..0720a90 --- /dev/null +++ b/packages/kernel/src/agent-self-assembly.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import * as auth from './auth.js'; +import * as agent from './agent.js'; +import { assembleAgent } from './agent-self-assembly.js'; +import * as conversation from './conversation.js'; +import * as policy from './policy.js'; +import * as store from './store.js'; +import * as thread from './thread.js'; +import { initWorkspace } from './workspace.js'; + +let workspacePath: string; + +beforeEach(() => { + workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-agent-self-assembly-')); + initWorkspace(workspacePath, { createReadme: false }); +}); + +afterEach(() => { + fs.rmSync(workspacePath, { recursive: true, force: true }); +}); + +describe('agent self-assembly', () => { + it('runs auth -> discovery -> claim -> plan-step activation end-to-end', () => { + const initResult = initWorkspace(workspacePath, { createReadme: false }); + const registration = agent.registerAgent(workspacePath, 'ops-agent', { + token: initResult.bootstrapTrustToken, + role: 'roles/ops.md', + capabilities: ['thread:claim'], + }); + expect(registration.apiKey).toBeDefined(); + + const workThread = thread.createThread( + workspacePath, + 'Investigate elevated error rate', + 'Locate root cause and propose remediation', + 'ops-agent', + ); + const executionConversation = conversation.createConversation( + workspacePath, + 'Ops execution', + 'ops-agent', + { threadRefs: [workThread.path] }, + ); + const seededStep = conversation.createPlanStep( + workspacePath, + 'Run first triage pass', + 'ops-agent', + { + conversationRef: executionConversation.conversation.path, + threadRef: workThread.path, + }, + ); + setStrictAuthMode(workspacePath); + + const result = assembleAgent(workspacePath, 'ops-agent', { + credentialToken: registration.apiKey, + advertise: { + capabilities: ['domain:ops'], + skills: ['incident-triage'], + adapters: ['shell-worker'], + }, + }); + + expect(result.authenticated).toBe(true); + expect(result.identityVerified).toBe(true); + expect(result.claimedThread?.path).toBe(workThread.path); + expect(result.planStep?.path).toBe(seededStep.path); + expect(result.planStep?.fields.status).toBe('active'); + expect(String(result.planStep?.fields.assignee)).toBe('ops-agent'); + expect(String(result.claimedThread?.fields.status)).toBe('active'); + expect(result.brief.actor).toBe('ops-agent'); + expect(result.capabilityProfile.skills).toContain('incident-triage'); + expect(result.capabilityProfile.adapters).toContain('shell-worker'); + expect(result.warnings).toEqual([]); + }); + + it('matches advertised skills/adapters/capabilities to the right thread', () => { + const initResult = initWorkspace(workspacePath, { createReadme: false }); + const registration = agent.registerAgent(workspacePath, 'router-agent', { + token: initResult.bootstrapTrustToken, + role: 'roles/ops.md', + capabilities: ['thread:claim'], + }); + expect(registration.apiKey).toBeDefined(); + + const unmatchedThread = thread.createThread( + workspacePath, + 'Requires code specialist', + 'Task requiring code-specialized profile', + 'router-agent', + { priority: 'urgent' }, + ); + store.update( + workspacePath, + unmatchedThread.path, + { + required_skills: ['deep-typescript'], + required_adapters: ['claude-code'], + }, + undefined, + 'system', + ); + + const matchedThread = thread.createThread( + workspacePath, + 'Requires ops triage', + 'Task requiring ops triage profile', + 'router-agent', + { priority: 'high' }, + ); + store.update( + workspacePath, + matchedThread.path, + { + required_capabilities: ['domain:ops'], + required_skills: ['incident-triage'], + required_adapters: ['shell-worker'], + }, + undefined, + 'system', + ); + setStrictAuthMode(workspacePath); + + const result = assembleAgent(workspacePath, 'router-agent', { + credentialToken: registration.apiKey, + advertise: { + capabilities: ['domain:ops'], + skills: ['incident-triage'], + adapters: ['shell-worker'], + }, + createPlanStepIfMissing: false, + }); + + expect(result.claimedThread?.path).toBe(matchedThread.path); + const unmatched = result.candidates.find((candidate) => candidate.thread.path === unmatchedThread.path); + expect(unmatched).toBeDefined(); + expect(unmatched?.matched).toBe(false); + expect(unmatched?.missing.skills).toContain('deep-typescript'); + expect(unmatched?.missing.adapters).toContain('claude-code'); + }); + + it('recovers stale claims and lets another agent take over', () => { + const initResult = initWorkspace(workspacePath, { createReadme: false }); + const firstAgent = agent.registerAgent(workspacePath, 'owner-agent', { + token: initResult.bootstrapTrustToken, + role: 'roles/ops.md', + capabilities: ['thread:claim', 'policy:manage'], + }); + expect(firstAgent.apiKey).toBeDefined(); + + const leasedThread = thread.createThread( + workspacePath, + 'Recoverable work item', + 'Must be reclaimed after stale lease', + 'owner-agent', + ); + const takeoverAgent = provisionOpsCredential(workspacePath, 'takeover-agent'); + setStrictAuthMode(workspacePath); + auth.runWithAuthContext({ credentialToken: firstAgent.apiKey, source: 'cli' }, () => { + thread.claim(workspacePath, leasedThread.path, 'owner-agent', { leaseTtlMinutes: 0 }); + }); + + const result = assembleAgent(workspacePath, 'takeover-agent', { + credentialToken: takeoverAgent.apiKey, + recoverStaleClaims: true, + recoveryRequired: true, + createPlanStepIfMissing: false, + }); + + expect(result.recovery?.reaped.map((entry) => entry.threadPath)).toContain(leasedThread.path); + expect(result.claimedThread?.path).toBe(leasedThread.path); + expect(String(result.claimedThread?.fields.owner)).toBe('takeover-agent'); + }); +}); + +function setStrictAuthMode(targetWorkspacePath: string): void { + const configPath = path.join(targetWorkspacePath, '.workgraph', 'server.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record; + config.auth = { + mode: 'strict', + allowUnauthenticatedFallback: false, + }; + fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8'); +} + +function provisionOpsCredential( + targetWorkspacePath: string, + actor: string, +): auth.IssueAgentCredentialResult { + const capabilities = ['thread:claim', 'thread:manage', 'dispatch:run', 'policy:manage', 'agent:register']; + policy.upsertParty(targetWorkspacePath, actor, { + roles: ['ops'], + capabilities, + }, { + actor: 'system', + skipAuthorization: true, + }); + return auth.issueAgentCredential(targetWorkspacePath, { + actor, + scopes: capabilities, + issuedBy: 'system', + }); +} diff --git a/packages/kernel/src/agent-self-assembly.ts b/packages/kernel/src/agent-self-assembly.ts new file mode 100644 index 0000000..f266911 --- /dev/null +++ b/packages/kernel/src/agent-self-assembly.ts @@ -0,0 +1,493 @@ +/** + * Agent self-assembly orchestration. + * + * Flow: + * 1) Optional bootstrap registration. + * 2) Authenticate actor identity/governance context. + * 3) Advertise capabilities + refresh presence heartbeat. + * 4) Discover workspace orientation + claimable threads. + * 5) Capability-match and claim a thread. + * 6) Begin plan-step execution for the claimed thread. + */ + +import * as auth from './auth.js'; +import * as agent from './agent.js'; +import * as conversation from './conversation.js'; +import * as dispatch from './dispatch.js'; +import * as orientation from './orientation.js'; +import * as policy from './policy.js'; +import * as store from './store.js'; +import * as thread from './thread.js'; +import type { PrimitiveInstance, WorkgraphBrief, WorkgraphStatusSnapshot } from './types.js'; + +const REQUIREMENT_TAG_PREFIXES = { + capability: 'requires:capability:', + skill: 'requires:skill:', + adapter: 'requires:adapter:', +} as const; + +export interface AgentCapabilityAdvertisement { + capabilities?: string[]; + skills?: string[]; + adapters?: string[]; +} + +export interface AgentCapabilityProfile { + agentName: string; + capabilities: string[]; + skills: string[]; + adapters: string[]; + advertisedCapabilityTokens: string[]; +} + +export interface ThreadCapabilityRequirements { + capabilities: string[]; + skills: string[]; + adapters: string[]; +} + +export interface ThreadCapabilityMatch { + thread: PrimitiveInstance; + requirements: ThreadCapabilityRequirements; + missing: ThreadCapabilityRequirements; + matched: boolean; +} + +export interface AgentSelfAssemblyOptions { + credentialToken?: string; + bootstrapToken?: string; + role?: string; + registerActor?: string; + recoverStaleClaims?: boolean; + recoveryActor?: string; + recoveryLimit?: number; + recoveryRequired?: boolean; + spaceRef?: string; + leaseTtlMinutes?: number; + advertise?: AgentCapabilityAdvertisement; + createPlanStepIfMissing?: boolean; +} + +export interface AgentSelfAssemblyResult { + agentName: string; + authenticated: boolean; + identityVerified: boolean; + registration?: agent.AgentRegistrationResult; + presence?: PrimitiveInstance; + capabilityProfile: AgentCapabilityProfile; + status: WorkgraphStatusSnapshot; + brief: WorkgraphBrief; + candidates: ThreadCapabilityMatch[]; + claimedThread?: PrimitiveInstance; + planStep?: PrimitiveInstance; + conversationPath?: string; + recovery?: thread.ReapStaleClaimsResult; + warnings: string[]; +} + +export function assembleAgent( + workspacePath: string, + agentName: string, + options: AgentSelfAssemblyOptions = {}, +): AgentSelfAssemblyResult { + const normalizedAgent = normalizeActorId(agentName); + if (!normalizedAgent) { + throw new Error(`Invalid agent name "${agentName}".`); + } + + const warnings: string[] = []; + const registration = maybeBootstrapRegister(workspacePath, normalizedAgent, options); + const effectiveCredential = readOptionalString(options.credentialToken) ?? registration?.apiKey; + + return withCredentialContext(effectiveCredential, () => { + const decision = auth.authorizeMutation(workspacePath, { + actor: normalizedAgent, + action: 'agent.self-assembly', + target: '.workgraph/self-assembly', + requiredCapabilities: ['thread:claim', 'thread:manage', 'dispatch:run'], + }); + if (!decision.allowed) { + throw new Error(decision.reason ?? `Self-assembly denied for "${normalizedAgent}".`); + } + + const capabilityProfile = buildCapabilityProfile(workspacePath, normalizedAgent, options, registration); + const presence = advertisePresence(workspacePath, normalizedAgent, capabilityProfile, warnings); + const recovery = maybeRecoverStaleClaims(workspacePath, normalizedAgent, options, warnings); + const status = orientation.statusSnapshot(workspacePath); + const brief = orientation.brief(workspacePath, normalizedAgent, { nextCount: 10, recentCount: 20 }); + + const readyThreads = options.spaceRef + ? thread.listReadyThreadsInSpace(workspacePath, options.spaceRef) + : thread.listReadyThreads(workspacePath); + const candidates = readyThreads.map((readyThread) => matchThreadToAgent(readyThread, capabilityProfile)); + const matchedCandidates = candidates.filter((candidate) => candidate.matched); + + const claimedThread = claimFirstMatchedThread( + workspacePath, + normalizedAgent, + matchedCandidates, + options.leaseTtlMinutes, + warnings, + ); + const stepStart = claimedThread + ? beginPlanStepExecution(workspacePath, claimedThread, normalizedAgent, options) + : undefined; + + return { + agentName: normalizedAgent, + authenticated: true, + identityVerified: decision.identityVerified, + ...(registration ? { registration } : {}), + ...(presence ? { presence } : {}), + capabilityProfile, + status, + brief, + candidates, + ...(claimedThread ? { claimedThread } : {}), + ...(stepStart?.planStep ? { planStep: stepStart.planStep } : {}), + ...(stepStart?.conversationPath ? { conversationPath: stepStart.conversationPath } : {}), + ...(recovery ? { recovery } : {}), + warnings, + }; + }); +} + +export function matchThreadToAgent( + threadInstance: PrimitiveInstance, + capabilityProfile: AgentCapabilityProfile, +): ThreadCapabilityMatch { + const requirements = readThreadCapabilityRequirements(threadInstance); + const missingCapabilities = requirements.capabilities + .filter((requiredCapability) => !capabilitySatisfied(capabilityProfile.capabilities, requiredCapability)); + const missingSkills = requirements.skills + .filter((requiredSkill) => !capabilityProfile.skills.includes(requiredSkill)); + const missingAdapters = requirements.adapters + .filter((requiredAdapter) => !capabilityProfile.adapters.includes(requiredAdapter)); + + return { + thread: threadInstance, + requirements, + missing: { + capabilities: missingCapabilities, + skills: missingSkills, + adapters: missingAdapters, + }, + matched: missingCapabilities.length === 0 && missingSkills.length === 0 && missingAdapters.length === 0, + }; +} + +function maybeBootstrapRegister( + workspacePath: string, + agentName: string, + options: AgentSelfAssemblyOptions, +): agent.AgentRegistrationResult | undefined { + const bootstrapToken = readOptionalString(options.bootstrapToken); + if (!bootstrapToken) return undefined; + return agent.registerAgent(workspacePath, agentName, { + token: bootstrapToken, + ...(options.role ? { role: options.role } : {}), + ...(options.advertise?.capabilities ? { capabilities: options.advertise.capabilities } : {}), + actor: readOptionalString(options.registerActor) ?? agentName, + }); +} + +function buildCapabilityProfile( + workspacePath: string, + agentName: string, + options: AgentSelfAssemblyOptions, + registration: agent.AgentRegistrationResult | undefined, +): AgentCapabilityProfile { + const existingPresence = agent.getPresence(workspacePath, agentName); + const policyParty = policy.getParty(workspacePath, agentName); + const advertised = options.advertise ?? {}; + + const mergedCapabilities = dedupeStrings([ + ...asStringList(existingPresence?.fields.capabilities), + ...(registration?.capabilities ?? []), + ...(policyParty?.capabilities ?? []), + ...asStringList(advertised.capabilities), + ]); + const mergedSkills = dedupeStrings([ + ...extractScopedValues(mergedCapabilities, 'skill:'), + ...asStringList(advertised.skills), + ]); + const mergedAdapters = dedupeStrings([ + ...extractScopedValues(mergedCapabilities, 'adapter:'), + ...asStringList(advertised.adapters), + ]); + const advertisedCapabilityTokens = dedupeStrings([ + ...mergedCapabilities, + ...mergedSkills.map((skillName) => `skill:${skillName}`), + ...mergedAdapters.map((adapterName) => `adapter:${adapterName}`), + ]); + + return { + agentName, + capabilities: mergedCapabilities, + skills: mergedSkills, + adapters: mergedAdapters, + advertisedCapabilityTokens, + }; +} + +function advertisePresence( + workspacePath: string, + agentName: string, + capabilityProfile: AgentCapabilityProfile, + warnings: string[], +): PrimitiveInstance | undefined { + try { + return agent.heartbeat(workspacePath, agentName, { + actor: agentName, + status: 'online', + capabilities: capabilityProfile.advertisedCapabilityTokens, + }); + } catch (error) { + warnings.push(`Presence advertisement failed: ${errorMessage(error)}`); + return undefined; + } +} + +function maybeRecoverStaleClaims( + workspacePath: string, + agentName: string, + options: AgentSelfAssemblyOptions, + warnings: string[], +): thread.ReapStaleClaimsResult | undefined { + if (options.recoverStaleClaims === false) return undefined; + const recoveryActor = readOptionalString(options.recoveryActor) ?? agentName; + try { + return thread.reapStaleClaims(workspacePath, recoveryActor, { + ...(typeof options.recoveryLimit === 'number' ? { limit: options.recoveryLimit } : {}), + }); + } catch (error) { + if (options.recoveryRequired) { + throw error; + } + warnings.push(`Stale-claim recovery skipped: ${errorMessage(error)}`); + return undefined; + } +} + +function claimFirstMatchedThread( + workspacePath: string, + actor: string, + candidates: ThreadCapabilityMatch[], + leaseTtlMinutes: number | undefined, + warnings: string[], +): PrimitiveInstance | undefined { + for (const candidate of candidates) { + try { + const claimed = dispatch.claimThread(workspacePath, candidate.thread.path, actor).thread; + if (typeof leaseTtlMinutes === 'number') { + thread.heartbeatClaim(workspacePath, actor, claimed.path, { ttlMinutes: leaseTtlMinutes }); + } + return claimed; + } catch (error) { + warnings.push(`Claim failed for ${candidate.thread.path}: ${errorMessage(error)}`); + } + } + return undefined; +} + +function beginPlanStepExecution( + workspacePath: string, + claimedThread: PrimitiveInstance, + actor: string, + options: AgentSelfAssemblyOptions, +): { planStep?: PrimitiveInstance; conversationPath?: string } { + const createPlanStepIfMissing = options.createPlanStepIfMissing !== false; + let conversationPath: string | undefined = findConversationForThread(workspacePath, claimedThread.path); + + if (!conversationPath && createPlanStepIfMissing) { + const createdConversation = conversation.createConversation( + workspacePath, + `Execution: ${String(claimedThread.fields.title ?? claimedThread.path)}`, + actor, + { + threadRefs: [claimedThread.path], + }, + ); + conversationPath = createdConversation.conversation.path; + } + + let selectedStep = findPlanStepForExecution(workspacePath, claimedThread.path, actor); + if (!selectedStep && createPlanStepIfMissing && conversationPath) { + selectedStep = conversation.createPlanStep( + workspacePath, + `Execute ${String(claimedThread.fields.title ?? claimedThread.path)}`, + actor, + { + conversationRef: conversationPath, + threadRef: claimedThread.path, + assignee: actor, + }, + ); + } + + if (selectedStep && !readOptionalString(selectedStep.fields.assignee)) { + selectedStep = store.update( + workspacePath, + selectedStep.path, + { assignee: actor }, + undefined, + actor, + ); + } + + if (selectedStep && String(selectedStep.fields.status ?? '').toLowerCase() !== 'active') { + selectedStep = conversation.updatePlanStepStatus( + workspacePath, + selectedStep.path, + 'active', + actor, + ); + } + + if (conversationPath) { + conversation.appendConversationMessage( + workspacePath, + conversationPath, + actor, + `Self-assembly claimed ${claimedThread.path} and started execution.`, + { + kind: 'system', + eventType: 'self-assembly', + threadRef: claimedThread.path, + }, + ); + } + + return { + ...(selectedStep ? { planStep: selectedStep } : {}), + ...(conversationPath ? { conversationPath } : {}), + }; +} + +function findConversationForThread(workspacePath: string, threadPath: string): string | undefined { + const conversations = conversation.listConversations(workspacePath, { threadRef: threadPath }); + return conversations[0]?.conversation.path; +} + +function findPlanStepForExecution( + workspacePath: string, + threadPath: string, + actor: string, +): PrimitiveInstance | undefined { + const candidates = conversation.listPlanSteps(workspacePath, { threadRef: threadPath }); + return candidates.find((step) => { + const status = String(step.fields.status ?? '').trim().toLowerCase(); + if (status !== 'open' && status !== 'active') return false; + const assignee = readOptionalString(step.fields.assignee); + return !assignee || assignee === actor; + }); +} + +function readThreadCapabilityRequirements(threadInstance: PrimitiveInstance): ThreadCapabilityRequirements { + const capabilityRequirements = dedupeStrings([ + ...asStringList(threadInstance.fields.required_capabilities), + ...asStringList(threadInstance.fields.requiredCapabilities), + ...extractTagRequirements(threadInstance.fields.tags, REQUIREMENT_TAG_PREFIXES.capability), + ]); + const skillRequirements = dedupeStrings([ + ...asStringList(threadInstance.fields.required_skills), + ...asStringList(threadInstance.fields.requiredSkills), + ...extractTagRequirements(threadInstance.fields.tags, REQUIREMENT_TAG_PREFIXES.skill), + ]); + const adapterRequirements = dedupeStrings([ + ...asStringList(threadInstance.fields.required_adapters), + ...asStringList(threadInstance.fields.requiredAdapters), + ...extractTagRequirements(threadInstance.fields.tags, REQUIREMENT_TAG_PREFIXES.adapter), + ]); + + return { + capabilities: capabilityRequirements, + skills: skillRequirements, + adapters: adapterRequirements, + }; +} + +function extractTagRequirements(value: unknown, prefix: string): string[] { + return asStringList(value) + .filter((tag) => tag.startsWith(prefix)) + .map((tag) => tag.slice(prefix.length)) + .filter(Boolean); +} + +function capabilitySatisfied(grantedCapabilities: string[], requiredCapability: string): boolean { + const normalizedRequired = normalizeToken(requiredCapability); + if (!normalizedRequired) return true; + for (const grantedCapability of grantedCapabilities) { + if (grantedCapability === '*') return true; + if (grantedCapability === normalizedRequired) return true; + if ( + grantedCapability.endsWith(':*') && + normalizedRequired.startsWith(`${grantedCapability.slice(0, -2)}:`) + ) { + return true; + } + } + return false; +} + +function withCredentialContext(credentialToken: string | undefined, fn: () => T): T { + const token = readOptionalString(credentialToken); + if (!token) return fn(); + return auth.runWithAuthContext({ credentialToken: token, source: 'internal' }, fn); +} + +function normalizeActorId(value: unknown): string { + return String(value ?? '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-|-$/g, ''); +} + +function normalizeToken(value: unknown): string { + return String(value ?? '') + .trim() + .toLowerCase(); +} + +function extractScopedValues(tokens: string[], prefix: string): string[] { + return tokens + .filter((token) => token.startsWith(prefix)) + .map((token) => token.slice(prefix.length)) + .filter(Boolean); +} + +function asStringList(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .map((entry) => normalizeToken(entry)) + .filter(Boolean); + } + if (typeof value === 'string') { + return value + .split(',') + .map((entry) => normalizeToken(entry)) + .filter(Boolean); + } + return []; +} + +function dedupeStrings(values: string[]): string[] { + const unique = new Set(); + for (const value of values) { + const normalized = normalizeToken(value); + if (!normalized) continue; + unique.add(normalized); + } + return [...unique]; +} + +function readOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts index ab63040..3cefd89 100644 --- a/packages/kernel/src/index.ts +++ b/packages/kernel/src/index.ts @@ -27,6 +27,7 @@ export * as adapterHttpWebhook from './adapter-http-webhook.js'; export * as adapterShellWorker from './adapter-shell-worker.js'; export * from './runtime-adapter-contracts.js'; export * from './adapter-shell-worker.js'; +export * from './agent-self-assembly.js'; export * from './skill.js'; export * as policy from './policy.js'; export * as bases from './bases.js'; @@ -38,6 +39,7 @@ export * as autonomyDaemon from './autonomy-daemon.js'; export * as commandCenter from './command-center.js'; export * as board from './board.js'; export * as agent from './agent.js'; +export * as agentSelfAssembly from './agent-self-assembly.js'; export * as intake from './intake.js'; export * as onboard from './onboard.js'; export * as skill from './skill.js';