first commit
This commit is contained in:
32
.env.example
Normal file
32
.env.example
Normal file
@@ -0,0 +1,32 @@
|
||||
# OpenAI Codex SDK
|
||||
CODEX_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=
|
||||
CODEX_SKIP_GIT_CHECK=true
|
||||
MCP_CONFIG_PATH=./mcp.config.json
|
||||
|
||||
# Anthropic Claude Agent SDK
|
||||
ANTHROPIC_API_KEY=
|
||||
CLAUDE_MODEL=
|
||||
CLAUDE_CODE_PATH=
|
||||
|
||||
# Agent management limits
|
||||
AGENT_MAX_CONCURRENT=4
|
||||
AGENT_MAX_SESSION=2
|
||||
AGENT_MAX_RECURSIVE_DEPTH=3
|
||||
|
||||
# Schema-driven orchestration limits
|
||||
AGENT_STATE_ROOT=.ai_ops/state
|
||||
AGENT_TOPOLOGY_MAX_DEPTH=4
|
||||
AGENT_TOPOLOGY_MAX_RETRIES=2
|
||||
AGENT_RELATIONSHIP_MAX_CHILDREN=4
|
||||
|
||||
# Resource provisioning (hard + soft constraints)
|
||||
AGENT_WORKTREE_ROOT=.ai_ops/worktrees
|
||||
AGENT_WORKTREE_BASE_REF=HEAD
|
||||
AGENT_PORT_BASE=36000
|
||||
AGENT_PORT_BLOCK_SIZE=32
|
||||
AGENT_PORT_BLOCK_COUNT=512
|
||||
AGENT_PORT_PRIMARY_OFFSET=0
|
||||
AGENT_PORT_LOCK_DIR=.ai_ops/locks/ports
|
||||
AGENT_DISCOVERY_FILE_RELATIVE_PATH=.agent-context/resources.json
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
mcp.config.json
|
||||
.ai_ops
|
||||
.agent-context
|
||||
61
AGENTS.md
Normal file
61
AGENTS.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Agent Instructions
|
||||
|
||||
## Goals
|
||||
- Keep runtime behavior explicit and predictable.
|
||||
- Keep integration points documented when features are added.
|
||||
- Enforce a consistent quality gate before merging changes.
|
||||
|
||||
## Project Organization
|
||||
- `src/agents`: session lifecycle, limits, resource provisioning, and schema-driven orchestration.
|
||||
- `src/mcp`: MCP config types, conversion, and handler policy resolution.
|
||||
- `src/examples`: provider-specific execution entrypoints.
|
||||
- `tests`: focused unit tests for orchestration and config behavior.
|
||||
|
||||
## Engineering Standards
|
||||
- TypeScript strict mode stays enabled.
|
||||
- Exported interfaces should be typed explicitly and validated at boundaries.
|
||||
- New behavior should include at least one test if it changes control flow or config parsing.
|
||||
- Avoid provider-specific policy in shared modules unless there is a clear extension point.
|
||||
- **When adding new runtime limits or resource constraints, ensure they are added to `.env.example`.**
|
||||
|
||||
## Runtime Environment Variables
|
||||
- Agent manager limits:
|
||||
- `AGENT_MAX_CONCURRENT`
|
||||
- `AGENT_MAX_SESSION`
|
||||
- `AGENT_MAX_RECURSIVE_DEPTH`
|
||||
- Orchestration/context limits:
|
||||
- `AGENT_STATE_ROOT`
|
||||
- `AGENT_TOPOLOGY_MAX_DEPTH`
|
||||
- `AGENT_TOPOLOGY_MAX_RETRIES`
|
||||
- `AGENT_RELATIONSHIP_MAX_CHILDREN`
|
||||
- Provisioning/resource controls:
|
||||
- `AGENT_WORKTREE_ROOT`
|
||||
- `AGENT_WORKTREE_BASE_REF`
|
||||
- `AGENT_PORT_BASE`
|
||||
- `AGENT_PORT_BLOCK_SIZE`
|
||||
- `AGENT_PORT_BLOCK_COUNT`
|
||||
- `AGENT_PORT_PRIMARY_OFFSET`
|
||||
- `AGENT_PORT_LOCK_DIR`
|
||||
- `AGENT_DISCOVERY_FILE_RELATIVE_PATH`
|
||||
|
||||
## Documentation Standards
|
||||
- Update `README.md` for user-facing behavior.
|
||||
- **Update this file for any new environment variables related to agent context or limits.**
|
||||
- Add design/maintenance notes in `docs/` for non-trivial architecture decisions.
|
||||
|
||||
## Quality Gate
|
||||
|
||||
Run this before opening a PR:
|
||||
|
||||
```bash
|
||||
npm run verify
|
||||
```
|
||||
|
||||
Equivalent individual commands:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
npm run check:tests
|
||||
npm run test
|
||||
npm run build
|
||||
```
|
||||
322
README.md
Normal file
322
README.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# AI Ops: Schema-Driven Multi-Agent Orchestration Runtime
|
||||
|
||||
TypeScript runtime for deterministic multi-agent execution with:
|
||||
|
||||
- OpenAI Codex SDK integration (`@openai/codex-sdk`)
|
||||
- Anthropic Claude Agent SDK integration (`@anthropic-ai/claude-agent-sdk`)
|
||||
- Schema-validated orchestration (`AgentManifest`)
|
||||
- Stateless node handoffs via persisted state/context payloads
|
||||
- Resource provisioning (git worktrees + deterministic port ranges)
|
||||
- MCP configuration layer with handler-based policy hooks
|
||||
|
||||
## Current Status
|
||||
|
||||
- Provider entrypoints (`codex`, `claude`) run with session limits and resource provisioning.
|
||||
- Schema-driven orchestration is implemented as reusable modules under `src/agents`.
|
||||
- Recursive `AgentManager.runRecursiveAgent(...)` supports fanout/fan-in orchestration with abort propagation.
|
||||
- Tool clearance allowlist/banlist is modeled, but hard security enforcement is still a TODO at tool execution boundaries.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
- `src/agents`:
|
||||
- `manager.ts`: queue-based concurrency limits + recursive fanout/fan-in orchestration.
|
||||
- `runtime.ts`: env-driven runtime singletons and defaults.
|
||||
- `manifest.ts`: `AgentManifest` schema parsing + validation (strict DAG).
|
||||
- `persona-registry.ts`: prompt templating + persona behavior events.
|
||||
- `pipeline.ts`: actor-oriented DAG runner with retries and state-dependent routing.
|
||||
- `state-context.ts`: persisted state + stateless handoff reconstruction.
|
||||
- `provisioning.ts`: extensible resource orchestration + child suballocation support.
|
||||
- `orchestration.ts`: `SchemaDrivenExecutionEngine` facade.
|
||||
- `src/mcp`: MCP config types, conversions, and handler resolution.
|
||||
- `src/examples`: provider entrypoints (`codex.ts`, `claude.ts`).
|
||||
- `tests`: unit coverage for manager, manifest, pipeline/orchestration, state context, MCP, and provisioning behavior.
|
||||
- `docs/orchestration-engine.md`: design notes for the orchestration architecture.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env
|
||||
cp mcp.config.example.json mcp.config.json
|
||||
```
|
||||
|
||||
Fill in any values you need in `.env`.
|
||||
|
||||
## Run
|
||||
|
||||
Run Codex example:
|
||||
|
||||
```bash
|
||||
npm run codex -- "Summarize what this repository does."
|
||||
```
|
||||
|
||||
Run Claude example:
|
||||
|
||||
```bash
|
||||
npm run claude -- "Summarize what this repository does."
|
||||
```
|
||||
|
||||
Run via unified entrypoint:
|
||||
|
||||
```bash
|
||||
npm run dev -- codex "List potential improvements."
|
||||
npm run dev -- claude "List potential improvements."
|
||||
```
|
||||
|
||||
## Schema-Driven Orchestration
|
||||
|
||||
The orchestration engine is exposed as library modules (not yet wired into `src/index.ts` by default).
|
||||
|
||||
Core pieces:
|
||||
|
||||
- `parseAgentManifest(...)` validates the full orchestration schema.
|
||||
- `PersonaRegistry` injects runtime context into templated system prompts.
|
||||
- `PipelineExecutor` executes a strict DAG of actor nodes.
|
||||
- `FileSystemStateContextManager` enforces stateless handoffs.
|
||||
- `SchemaDrivenExecutionEngine` composes all of the above with env-driven limits.
|
||||
|
||||
### AgentManifest Overview
|
||||
|
||||
`AgentManifest` (schema version `"1"`) includes:
|
||||
|
||||
- `topologies`: any of `hierarchical`, `retry-unrolled`, `sequential`
|
||||
- `personas`: identity, prompt template, tool clearance metadata
|
||||
- `relationships`: parent-child persona edges and constraints
|
||||
- `pipeline`: strict DAG with entry node, nodes, and edges
|
||||
- `topologyConstraints`: max depth and retry ceilings
|
||||
|
||||
Edge routing supports:
|
||||
|
||||
- Event gates: `success`, `validation_fail`, `failure`, `always`, `onTaskComplete`, `onValidationFail`
|
||||
- Conditions:
|
||||
- `state_flag`
|
||||
- `history_has_event`
|
||||
- `file_exists`
|
||||
- `always`
|
||||
|
||||
Example manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "1",
|
||||
"topologies": ["hierarchical", "retry-unrolled", "sequential"],
|
||||
"personas": [
|
||||
{
|
||||
"id": "coder",
|
||||
"displayName": "Coder",
|
||||
"systemPromptTemplate": "Implement ticket {{ticket}} in repo {{repo}}",
|
||||
"toolClearance": {
|
||||
"allowlist": ["read_file", "write_file"],
|
||||
"banlist": ["rm"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"relationships": [],
|
||||
"pipeline": {
|
||||
"entryNodeId": "coder-1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "coder-1",
|
||||
"actorId": "coder_actor",
|
||||
"personaId": "coder",
|
||||
"constraints": { "maxRetries": 1 }
|
||||
}
|
||||
],
|
||||
"edges": []
|
||||
},
|
||||
"topologyConstraints": {
|
||||
"maxDepth": 4,
|
||||
"maxRetries": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Minimal Engine Usage
|
||||
|
||||
```ts
|
||||
import { SchemaDrivenExecutionEngine } from "./src/agents/orchestration.js";
|
||||
|
||||
const engine = new SchemaDrivenExecutionEngine({
|
||||
manifest,
|
||||
actorExecutors: {
|
||||
coder_actor: async ({ prompt, context, toolClearance }) => {
|
||||
// execute actor logic here
|
||||
return {
|
||||
status: "success",
|
||||
payload: {
|
||||
summary: "done"
|
||||
},
|
||||
stateFlags: {
|
||||
implemented: true
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
workspaceRoot: process.cwd(),
|
||||
runtimeContext: {
|
||||
repo: "ai_ops",
|
||||
ticket: "AIOPS-123"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const result = await engine.runSession({
|
||||
sessionId: "session-1",
|
||||
initialPayload: {
|
||||
task: "Implement feature"
|
||||
}
|
||||
});
|
||||
|
||||
console.log(result.records);
|
||||
```
|
||||
|
||||
## Stateless Handoffs and Context
|
||||
|
||||
The engine does not depend on conversational memory between nodes.
|
||||
|
||||
- Node inputs are written as handoff payloads to storage.
|
||||
- Each node execution reads a fresh context snapshot from disk.
|
||||
- Session state persists:
|
||||
- flags
|
||||
- metadata
|
||||
- history events
|
||||
|
||||
Default state root is controlled by `AGENT_STATE_ROOT`.
|
||||
|
||||
## Recursive Orchestration Contract
|
||||
|
||||
`AgentManager.runRecursiveAgent(...)` uses a strict two-phase fanout/fan-in model:
|
||||
|
||||
- Phase 1 (planner): agent execution returns either a terminal result or a fanout plan (`intents[]` + `aggregate(...)`).
|
||||
- Parent tokens are released before children are scheduled, avoiding deadlocks even when `AGENT_MAX_CONCURRENT=1`.
|
||||
- Children run in isolated deterministic session IDs (`<parent>_child_<index>`), each with their own `AbortSignal`.
|
||||
- Phase 2 (aggregator): once all children complete, the aggregate phase runs as a fresh invocation.
|
||||
|
||||
Optional child middleware hooks (`allocateForChild`, `releaseForChild`) let callers integrate provisioning/suballocation without coupling `AgentManager` to filesystem or git operations.
|
||||
|
||||
## Resource Provisioning
|
||||
|
||||
The provisioning layer separates:
|
||||
|
||||
- Hard constraints: actual resource allocation enforced before run.
|
||||
- Soft constraints: injected env vars, prompt sections, metadata, and discovery snapshot.
|
||||
|
||||
Built-in providers:
|
||||
|
||||
- `git-worktree`
|
||||
- `port-range`
|
||||
|
||||
Runtime injection includes:
|
||||
|
||||
- Working directory override
|
||||
- Injected env vars such as `AGENT_WORKTREE_PATH`, `AGENT_PORT_RANGE_START`, `AGENT_PORT_RANGE_END`, `AGENT_PORT_PRIMARY`
|
||||
- Discovery file path via `AGENT_DISCOVERY_FILE`
|
||||
|
||||
### Hierarchical Suballocation
|
||||
|
||||
Parent sessions can suballocate resources for child sessions using:
|
||||
|
||||
- `ResourceProvisioningOrchestrator.provisionChildSession(...)`
|
||||
- `buildChildResourceRequests(...)`
|
||||
|
||||
Behavior:
|
||||
|
||||
- Child worktrees are placed under a deterministic parent-scoped root.
|
||||
- Child port blocks are deterministically carved from the parent assigned range.
|
||||
|
||||
## MCP Configuration
|
||||
|
||||
Use `mcp.config.json` to configure shared and provider-specific MCP servers.
|
||||
|
||||
- `MCP_CONFIG_PATH` controls config location (default `./mcp.config.json`).
|
||||
- Shared server definitions are in `servers`.
|
||||
- Provider overrides:
|
||||
- `codex.mcp_servers`
|
||||
- `claude.mcpServers`
|
||||
- Handlers:
|
||||
- built-in `context7`
|
||||
- built-in `claude-task-master`
|
||||
- built-in `generic`
|
||||
- custom handlers via `registerMcpHandler(...)`
|
||||
|
||||
See `mcp.config.example.json` for a complete template.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Provider/Auth
|
||||
|
||||
- `CODEX_API_KEY`
|
||||
- `OPENAI_API_KEY`
|
||||
- `OPENAI_BASE_URL`
|
||||
- `CODEX_SKIP_GIT_CHECK`
|
||||
- `ANTHROPIC_API_KEY`
|
||||
- `CLAUDE_MODEL`
|
||||
- `CLAUDE_CODE_PATH`
|
||||
- `MCP_CONFIG_PATH`
|
||||
|
||||
### Agent Manager Limits
|
||||
|
||||
- `AGENT_MAX_CONCURRENT`
|
||||
- `AGENT_MAX_SESSION`
|
||||
- `AGENT_MAX_RECURSIVE_DEPTH`
|
||||
|
||||
### Orchestration Limits
|
||||
|
||||
- `AGENT_STATE_ROOT`
|
||||
- `AGENT_TOPOLOGY_MAX_DEPTH`
|
||||
- `AGENT_TOPOLOGY_MAX_RETRIES`
|
||||
- `AGENT_RELATIONSHIP_MAX_CHILDREN`
|
||||
|
||||
### Provisioning
|
||||
|
||||
- `AGENT_WORKTREE_ROOT`
|
||||
- `AGENT_WORKTREE_BASE_REF`
|
||||
- `AGENT_PORT_BASE`
|
||||
- `AGENT_PORT_BLOCK_SIZE`
|
||||
- `AGENT_PORT_BLOCK_COUNT`
|
||||
- `AGENT_PORT_PRIMARY_OFFSET`
|
||||
- `AGENT_PORT_LOCK_DIR`
|
||||
- `AGENT_DISCOVERY_FILE_RELATIVE_PATH`
|
||||
|
||||
Defaults are documented in `.env.example`.
|
||||
|
||||
## Quality Gate
|
||||
|
||||
Run the full pre-PR gate:
|
||||
|
||||
```bash
|
||||
npm run verify
|
||||
```
|
||||
|
||||
Equivalent individual commands:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
npm run check:tests
|
||||
npm run test
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Build and Start
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run start -- codex "Hello from built JS"
|
||||
```
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Tool clearance allowlist/banlist is currently metadata; enforcement is not yet wired into an execution sandbox.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/orchestration-engine.md`
|
||||
- OpenAI Codex SDK docs: https://developers.openai.com/codex/sdk/
|
||||
- Codex MCP config docs: https://developers.openai.com/codex/config#model-context-protocol-mcp_servers
|
||||
- Claude Agent SDK docs: https://platform.claude.com/docs/en/agent-sdk/overview
|
||||
36
docs/orchestration-engine.md
Normal file
36
docs/orchestration-engine.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Schema-Driven Orchestration Engine
|
||||
|
||||
## Why this exists
|
||||
|
||||
The orchestration runtime introduces explicit schema validation and deterministic execution rules for multi-agent pipelines. The design favors predictable behavior over implicit conversational memory.
|
||||
|
||||
## Main components
|
||||
|
||||
- `AgentManifest` schema (`src/agents/manifest.ts`): validates personas, relationships, topology constraints, and a strict DAG pipeline.
|
||||
- Persona registry (`src/agents/persona-registry.ts`): renders templated prompts with runtime context and routes behavioral events.
|
||||
- Stateful storage for stateless execution (`src/agents/state-context.ts`): each node execution reads payload + state from storage to get fresh context.
|
||||
- DAG pipeline runner (`src/agents/pipeline.ts`): executes actor nodes, evaluates state/history/repo conditions, enforces retry/depth limits.
|
||||
- Orchestration facade (`src/agents/orchestration.ts`): wires manifest + registry + pipeline + state manager with env-driven limits.
|
||||
- Hierarchical resource suballocation (`src/agents/provisioning.ts`): builds child `git-worktree` and child `port-range` requests from parent allocation data.
|
||||
- Recursive manager runtime (`src/agents/manager.ts`): queue-aware fanout/fan-in execution with fail-fast child cancellation and session-level abort propagation.
|
||||
|
||||
## Constraint model
|
||||
|
||||
- Relationship constraints: per-edge limits (`maxDepth`, `maxChildren`) and process-level cap (`AGENT_RELATIONSHIP_MAX_CHILDREN`).
|
||||
- Pipeline constraints: per-node retry limits and process-level cap (`AGENT_TOPOLOGY_MAX_RETRIES`).
|
||||
- Topology constraints: max depth and retries from manifest + env caps.
|
||||
|
||||
## Stateless handoffs
|
||||
|
||||
Node payloads are persisted under the state root. Nodes do not inherit in-memory conversational context from previous node runs. Fresh context is reconstructed from the handoff and persisted state each execution.
|
||||
|
||||
## Recursive execution model
|
||||
|
||||
- Recursive planning is schema-driven: a node returns child intents rather than imperatively spawning children.
|
||||
- Parent execution ends before child runs begin; the parent token is released and reacquired only for aggregate phase execution.
|
||||
- Child sessions use deterministic hierarchical IDs (`<parent>_child_<n>`) and are cancellable through parent session closure.
|
||||
- Resource orchestration remains external to `AgentManager` via middleware hooks for child allocation/release.
|
||||
|
||||
## Security note
|
||||
|
||||
Tool clearance allowlists/banlists are currently data-model stubs. Enforcement must be implemented in the tool execution boundary before relying on these policies for hard guarantees.
|
||||
176
human_only_TODO
Normal file
176
human_only_TODO
Normal file
@@ -0,0 +1,176 @@
|
||||
# what is it
|
||||
- a thing that gives me finer control around what agents are doing and what their contexts are
|
||||
- has concurrency
|
||||
- manages git workdirs and merges
|
||||
- can use any openai/anthropic models
|
||||
- can use multiple sets of creds
|
||||
|
||||
# in progress
|
||||
|
||||
# epic
|
||||
implementation of AgentManager.runRecursiveAgent
|
||||
|
||||
# primitives / assumptions
|
||||
- runRecursiveAgent is currently a stub (src/agents/manager.ts:100), and README confirms it’s intentionally
|
||||
unimplemented (README.md:16, README.md:304).
|
||||
- Core primitives exist:
|
||||
- queue + depth limits (src/agents/manager.ts:85, src/agents/manager.ts:117)
|
||||
- child resource suballocation (src/agents/provisioning.ts:262)
|
||||
- child persona planning (src/agents/orchestration.ts:146)
|
||||
- Baseline tests are green (npm test passes).
|
||||
|
||||
# concurrency and deadlocks
|
||||
|
||||
- deadlock policy
|
||||
- release parent token before awaiting children, specifically implementing a strict fan out/fan in policy
|
||||
- a parent agent should never actively "wait" or suspend while holding a concurrency token
|
||||
- the parent's execution should simply terminate by returning a "Fanout Plan" (an array of child intents/payloads).
|
||||
- Once returned, the Orchestrator reclaims the parent's token and queues the children.
|
||||
- Once all the child nodes in that fanout complete their work, the Orchestrator schedules a completely new "Aggregator" node (or Phase 2 of the parent) and passes the children's outputs into it.
|
||||
- This guarantees we will never hit a deadlock, even if maxConcurrentAgents=1, because a parent and its children will never hold capacity at the same time.
|
||||
- api contracts for recursion
|
||||
- Do not give the agent a spawnChild() callback that it runs imperatively mid-stream. Instead, when runRecursiveAgent finishes its thought process, it should return an array of intent objects: [{ persona: "Coder", task: "build X", context: {...} }, ...]. The DAG Orchestrator reads this array and schedules the children. This keeps the execution engine completely schema-driven and observable.
|
||||
- failure/cancellation semantics
|
||||
- Cancel active work via AbortController and Fail-Fast
|
||||
- Every agent invocation must accept an AbortSignal. If a parent session is closed or fails catastrophically, the Orchestrator fires the AbortController. The SDKs (@openai/codex-sdk, @anthropic-ai/claude-agent-sdk) natively support passing standard web AbortSignals to cancel in-flight API requests. The pipeline owns the retry logic, not the agent.
|
||||
|
||||
# topology and state management
|
||||
|
||||
- session topology model
|
||||
- child sessions with hierarchical ids
|
||||
- Children must run in entirely isolated child sessions. If the parent is session_xyz, children are session_xyz_child_1. This creates a deterministic tree. If session_xyz is cancelled, the Orchestrator can easily regex/filter and cascade the cancellation to all sessions starting with that prefix
|
||||
- state merge semantics
|
||||
- States stay isolated; merging is an explicit DAG node action
|
||||
- given our stateless handoffs, children do not magically merge their state back into the parent. Child A writes to its own isolated sub-worktree and outputs a final JSON payload.
|
||||
- merge behavior can be dictated by either:
|
||||
- an explicitly defined merge agent
|
||||
- a deterministic git merge script in the orchestrator
|
||||
- relationship graph semantics for recursion
|
||||
- Enforce acyclic relationships at the manifest level, rely on depth caps for dynamic fan-outs
|
||||
- AgentManifest validator should throw an error on startup if it detects a hardcoded cycle (A -> B -> A).
|
||||
- For dynamic recursion (where an agent decides at runtime to spawn 3 sub-agents), rely strictly on AGENT_MAX_RECURSIVE_DEPTH.
|
||||
- If an agent tries to spawn a child at depth limit + 1, the Orchestrator rejects the intent and returns a hard error payload to the parent
|
||||
|
||||
# resources and boundaries
|
||||
|
||||
- resource inheritance boundary
|
||||
- AgentManager stays concurrency-only; orchestrate via a Middleware layer
|
||||
- DAG Orchestrator / AgentManager and Resource Provisioner should remain decoupled
|
||||
- When the AgentManager pops a child task off the queue to run it, it should emit an event or call a middleware: provisioner.allocateFor(childSessionId, parentSessionId).
|
||||
- The provisioner reads the parent's resources, slices them (e.g., sub-allocating a chunk of ports or creating a nested git worktree), and injects them into the child's environment.
|
||||
- The AgentManager never touches Git or files directly
|
||||
|
||||
# test acceptance criteria
|
||||
|
||||
- runRecursiveAgent test suites
|
||||
- The Deadlock Test: Set maxConcurrentAgents=1. Run a parent that spawns a child. Assert that the system resolves successfully (proving the parent yielded its token).
|
||||
- The Depth Test: Set AGENT_MAX_RECURSIVE_DEPTH=2. Have an agent spawn a child, which spawns a grandchild, which tries to spawn a great-grandchild. Assert the 3rd spawn is rejected and the error propagates up gracefully.
|
||||
- The Abort Test: Start a parent with a 5-second sleep task, cancel the session at 1 second. Assert that the underlying LLM SDK handles were aborted and resources were released.
|
||||
- The Isolation Test: Spawn two children concurrently. Assert they are assigned non-overlapping port ranges and isolated worktree paths.
|
||||
|
||||
# Scheduled
|
||||
- security implementation
|
||||
- persona definitions
|
||||
- product
|
||||
- task
|
||||
- coder
|
||||
- tester
|
||||
- git
|
||||
- handle basic git validation/maintenance
|
||||
- edit + merge when conflict is low
|
||||
- pass to dev when conflict is big
|
||||
|
||||
- need to untangle
|
||||
- what goes where in terms of DAG definition vs app logic vs agent behavior
|
||||
- events
|
||||
- what events do we have
|
||||
- what personas care about what events
|
||||
- how should a persona respond to an event
|
||||
- where is this defined
|
||||
- success/failure/retry policy definitions
|
||||
- where does this go?
|
||||
- what are they?
|
||||
|
||||
|
||||
|
||||
- task management flow
|
||||
- init
|
||||
- planning
|
||||
- prioritization
|
||||
- dependency graph
|
||||
- subtasks
|
||||
- task/subtask status updates (pending, in progress, done, failed)
|
||||
|
||||
# Considering
|
||||
- model selection per task/session/agent
|
||||
- agent "notebook"
|
||||
- agent run log
|
||||
- agent persona support
|
||||
- ping pong support - ie. product agent > dev agent, dev agent needs clarification = ping pong back to product. same with tester > dev.
|
||||
- resume session aspect of this
|
||||
- max ping pong length ie. tester can only pass back once otherwise mark as failed
|
||||
- max ping pong length per relationship ie dev:git can ping pong 4 times, dev:product only once, etc
|
||||
- git orchestration
|
||||
- merging
|
||||
- symlinks
|
||||
- security
|
||||
- whatever existing thing has
|
||||
- banned commands (look up a git repo for this)
|
||||
- front end
|
||||
- list available models
|
||||
- specific workflows
|
||||
- ui
|
||||
- ci/cd
|
||||
- review
|
||||
- testing
|
||||
# Defer
|
||||
# Won't Do
|
||||
|
||||
|
||||
# Completed
|
||||
1. boilerplate typescript project for claude
|
||||
- mcp server support
|
||||
- generic mcp handlers
|
||||
- specific mcp handlers for
|
||||
- context7
|
||||
- claude task manager
|
||||
- concurrency, configurable max agent and max depth
|
||||
- Extensible Resource Provisioning
|
||||
- hard constraints
|
||||
- soft constraints
|
||||
- basic hygeine run
|
||||
# epic
|
||||
- agent orchestration system improvements
|
||||
# module 1
|
||||
- schema driven execution engine
|
||||
- specific definitions handled in AgentManifest schema
|
||||
- persona registry
|
||||
- templated system prompts injected with runtime context
|
||||
- tool clearances (stub this for now, add TODO for security implementation)
|
||||
- allowlist
|
||||
- banlist
|
||||
- behavioral event handlers
|
||||
- define how personas react to specific events ie. onTaskComplete, onValidationFail
|
||||
# module 2
|
||||
- actor oriented pipeline constrained by a strict directed acyclic graph
|
||||
- relationship + pipeline graphs
|
||||
- multi level topology
|
||||
- hierarchical ie parent spawns 3 coder children
|
||||
- unrolled retry pipelines ie coder1 > QA1 > Coder2 > QA2
|
||||
- sequential ie product > task > coder > QA > git
|
||||
- support for constraint definition for each concept (relationship, pipeline, topology)
|
||||
- ie max depth, max retries
|
||||
- state dependent routings
|
||||
- support branching logic based on project history or repository state ie. project init requires product agent to generate prd, then task agent needs to create roadmap, once those exist future sessions skip those agents and go straight to coder agents
|
||||
# module 3
|
||||
- state/context manager
|
||||
- stateless handoffs
|
||||
- state and context are passed forwards through payloads via worktree/storage, not conversational memory
|
||||
- fresh context per node execution
|
||||
# module 4
|
||||
- resource provisioning
|
||||
- hierarchical resource suballocation
|
||||
- when a parent agent spawns children, handle local resource management
|
||||
- branche/sub-worktree provisioning
|
||||
- suballocating deterministic port range provisioning
|
||||
- extensibility to support future resource types
|
||||
46
mcp.config.example.json
Normal file
46
mcp.config.example.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"handlerSettings": {
|
||||
"context7": {
|
||||
"enabledByDefault": true
|
||||
},
|
||||
"claude-task-master": {
|
||||
"enabledByDefault": true
|
||||
}
|
||||
},
|
||||
"servers": {
|
||||
"context7": {
|
||||
"handler": "context7",
|
||||
"type": "http",
|
||||
"url": "https://your-context7-mcp.example.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer REPLACE_ME"
|
||||
}
|
||||
},
|
||||
"claude-task-master": {
|
||||
"handler": "claude-task-master",
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "REPLACE_WITH_CLAUDE_TASK_MASTER_MCP_COMMAND"]
|
||||
},
|
||||
"local-files": {
|
||||
"handler": "generic",
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "."]
|
||||
},
|
||||
"docs-http": {
|
||||
"handler": "generic",
|
||||
"type": "http",
|
||||
"url": "https://your-mcp-endpoint.example.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer REPLACE_ME"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codex": {
|
||||
"mcp_servers": {}
|
||||
},
|
||||
"claude": {
|
||||
"mcpServers": {}
|
||||
}
|
||||
}
|
||||
1079
package-lock.json
generated
Normal file
1079
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
package.json
Normal file
38
package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "ai_ops",
|
||||
"version": "1.0.0",
|
||||
"description": "Boilerplate TypeScript project using OpenAI Codex SDK and Anthropic Claude Agent SDK",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"check": "tsc -p tsconfig.json --noEmit",
|
||||
"check:tests": "tsc -p tsconfig.test.json --noEmit",
|
||||
"test": "node --import tsx/esm --test tests/**/*.test.ts",
|
||||
"verify": "npm run check && npm run check:tests && npm run test && npm run build",
|
||||
"dev": "node --import tsx/esm src/index.ts",
|
||||
"codex": "node --import tsx/esm src/examples/codex.ts",
|
||||
"claude": "node --import tsx/esm src/examples/claude.ts",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"keywords": [
|
||||
"typescript",
|
||||
"openai",
|
||||
"codex",
|
||||
"anthropic",
|
||||
"claude",
|
||||
"sdk"
|
||||
],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.50",
|
||||
"@openai/codex-sdk": "^0.104.0",
|
||||
"dotenv": "^17.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.3.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
686
src/agents/manager.ts
Normal file
686
src/agents/manager.ts
Normal file
@@ -0,0 +1,686 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export type AgentManagerLimits = {
|
||||
maxConcurrentAgents: number;
|
||||
maxSessionAgents: number;
|
||||
maxRecursiveDepth: number;
|
||||
};
|
||||
|
||||
type SessionState = {
|
||||
activeAgents: number;
|
||||
closed: boolean;
|
||||
abortController: AbortController;
|
||||
parentSessionId?: string;
|
||||
childSessionIds: Set<string>;
|
||||
};
|
||||
|
||||
type Waiter = {
|
||||
sessionId: string;
|
||||
depth: number;
|
||||
signal?: AbortSignal;
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
cleanupSignalListener?: () => void;
|
||||
};
|
||||
|
||||
function assertPositiveInteger(value: number, name: string): void {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`${name} must be a positive integer.`);
|
||||
}
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
return new Error(String(error));
|
||||
}
|
||||
|
||||
function toAbortError(reason: unknown): Error {
|
||||
if (reason instanceof Error) {
|
||||
return reason;
|
||||
}
|
||||
|
||||
const error = new Error(
|
||||
reason === undefined ? "The operation was aborted." : `The operation was aborted: ${String(reason)}.`,
|
||||
);
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw toAbortError(signal.reason);
|
||||
}
|
||||
}
|
||||
|
||||
function createLinkedAbortSignal(signals: Array<AbortSignal | undefined>): {
|
||||
signal: AbortSignal;
|
||||
cleanup: () => void;
|
||||
} {
|
||||
const controller = new AbortController();
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
for (const signal of signals) {
|
||||
if (!signal) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
controller.abort(signal.reason);
|
||||
break;
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort(signal.reason);
|
||||
}
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
cleanups.push(() => signal.removeEventListener("abort", onAbort));
|
||||
}
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
cleanup: () => {
|
||||
for (const cleanup of cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type RecursiveChildIntent = {
|
||||
persona: string;
|
||||
task: string;
|
||||
context?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type RecursiveRunContext<TIntent extends RecursiveChildIntent> = {
|
||||
sessionId: string;
|
||||
depth: number;
|
||||
signal: AbortSignal;
|
||||
parentSessionId?: string;
|
||||
intent?: TIntent;
|
||||
};
|
||||
|
||||
export type RecursiveChildResult<TIntent extends RecursiveChildIntent, TOutput> = {
|
||||
childSessionId: string;
|
||||
intent: TIntent;
|
||||
output: TOutput;
|
||||
};
|
||||
|
||||
export type RecursiveFanoutPlan<TIntent extends RecursiveChildIntent, TOutput> = {
|
||||
type: "fanout";
|
||||
intents: TIntent[];
|
||||
aggregate: (input: {
|
||||
sessionId: string;
|
||||
depth: number;
|
||||
signal: AbortSignal;
|
||||
childResults: ReadonlyArray<RecursiveChildResult<TIntent, TOutput>>;
|
||||
}) => Promise<TOutput> | TOutput;
|
||||
};
|
||||
|
||||
export type RecursiveCompleteResult<TOutput> = {
|
||||
type: "complete";
|
||||
output: TOutput;
|
||||
};
|
||||
|
||||
export type RecursiveRunOutput<TIntent extends RecursiveChildIntent, TOutput> =
|
||||
| TOutput
|
||||
| RecursiveCompleteResult<TOutput>
|
||||
| RecursiveFanoutPlan<TIntent, TOutput>;
|
||||
|
||||
type RecursiveChildOutcome = { status: "success" } | { status: "failure"; error: Error };
|
||||
|
||||
export type RecursiveChildMiddleware<TIntent extends RecursiveChildIntent> = {
|
||||
allocateForChild?: (input: {
|
||||
parentSessionId: string;
|
||||
childSessionId: string;
|
||||
depth: number;
|
||||
childIndex: number;
|
||||
childCount: number;
|
||||
intent: TIntent;
|
||||
signal: AbortSignal;
|
||||
}) => Promise<void> | void;
|
||||
releaseForChild?: (input: {
|
||||
parentSessionId: string;
|
||||
childSessionId: string;
|
||||
depth: number;
|
||||
childIndex: number;
|
||||
childCount: number;
|
||||
intent: TIntent;
|
||||
signal: AbortSignal;
|
||||
outcome: RecursiveChildOutcome;
|
||||
}) => Promise<void> | void;
|
||||
};
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isRecursiveFanoutPlan<TIntent extends RecursiveChildIntent, TOutput>(
|
||||
value: unknown,
|
||||
): value is RecursiveFanoutPlan<TIntent, TOutput> {
|
||||
if (!isObject(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.type !== "fanout") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Array.isArray(value.intents) && typeof value.aggregate === "function";
|
||||
}
|
||||
|
||||
function isRecursiveCompleteResult<TOutput>(value: unknown): value is RecursiveCompleteResult<TOutput> {
|
||||
if (!isObject(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return value.type === "complete" && "output" in value;
|
||||
}
|
||||
|
||||
function toRecursiveComplete<TOutput>(value: TOutput): RecursiveCompleteResult<TOutput> {
|
||||
return {
|
||||
type: "complete",
|
||||
output: value,
|
||||
};
|
||||
}
|
||||
|
||||
export class AgentSession {
|
||||
constructor(
|
||||
private readonly manager: AgentManager,
|
||||
public readonly id: string,
|
||||
) {}
|
||||
|
||||
async runAgent<T>(input: {
|
||||
depth?: number;
|
||||
signal?: AbortSignal;
|
||||
run: () => Promise<T> | T;
|
||||
}): Promise<T> {
|
||||
return this.manager.runInSession({
|
||||
sessionId: this.id,
|
||||
depth: input.depth ?? 0,
|
||||
signal: input.signal,
|
||||
run: input.run,
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.manager.closeSession(this.id);
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentManager {
|
||||
private readonly limits: AgentManagerLimits;
|
||||
private readonly sessions = new Map<string, SessionState>();
|
||||
private readonly waiters: Waiter[] = [];
|
||||
private activeAgents = 0;
|
||||
|
||||
constructor(limits: AgentManagerLimits) {
|
||||
assertPositiveInteger(limits.maxConcurrentAgents, "maxConcurrentAgents");
|
||||
assertPositiveInteger(limits.maxSessionAgents, "maxSessionAgents");
|
||||
assertPositiveInteger(limits.maxRecursiveDepth, "maxRecursiveDepth");
|
||||
this.limits = limits;
|
||||
}
|
||||
|
||||
createSession(
|
||||
sessionId: string = randomUUID(),
|
||||
options?: {
|
||||
parentSessionId?: string;
|
||||
},
|
||||
): AgentSession {
|
||||
if (this.sessions.has(sessionId)) {
|
||||
throw new Error(`Agent session "${sessionId}" already exists.`);
|
||||
}
|
||||
|
||||
const parentSessionId = options?.parentSessionId;
|
||||
if (parentSessionId) {
|
||||
const parent = this.sessions.get(parentSessionId);
|
||||
if (!parent || parent.closed) {
|
||||
throw new Error(`Parent agent session "${parentSessionId}" is not active.`);
|
||||
}
|
||||
}
|
||||
|
||||
this.sessions.set(sessionId, {
|
||||
activeAgents: 0,
|
||||
closed: false,
|
||||
abortController: new AbortController(),
|
||||
...(parentSessionId ? { parentSessionId } : {}),
|
||||
childSessionIds: new Set<string>(),
|
||||
});
|
||||
|
||||
if (parentSessionId) {
|
||||
this.sessions.get(parentSessionId)?.childSessionIds.add(sessionId);
|
||||
}
|
||||
|
||||
return new AgentSession(this, sessionId);
|
||||
}
|
||||
|
||||
closeSession(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.closed = true;
|
||||
if (!session.abortController.signal.aborted) {
|
||||
session.abortController.abort(new Error(`Agent session "${sessionId}" was closed.`));
|
||||
}
|
||||
this.rejectWaitersForSession(sessionId);
|
||||
|
||||
for (const childSessionId of [...session.childSessionIds]) {
|
||||
this.closeSession(childSessionId);
|
||||
}
|
||||
|
||||
if (session.activeAgents === 0 && session.childSessionIds.size === 0) {
|
||||
this.deleteSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
async runInSession<T>(input: {
|
||||
sessionId: string;
|
||||
depth: number;
|
||||
signal?: AbortSignal;
|
||||
run: () => Promise<T> | T;
|
||||
}): Promise<T> {
|
||||
const { sessionId, depth, signal, run } = input;
|
||||
this.assertDepth(depth);
|
||||
await this.acquire(sessionId, depth, signal);
|
||||
try {
|
||||
throwIfAborted(signal);
|
||||
return await run();
|
||||
} finally {
|
||||
this.release(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
async runRecursiveAgent<TIntent extends RecursiveChildIntent, TOutput>(input: {
|
||||
sessionId: string;
|
||||
depth: number;
|
||||
signal?: AbortSignal;
|
||||
run: (
|
||||
input: RecursiveRunContext<TIntent>,
|
||||
) => Promise<RecursiveRunOutput<TIntent, TOutput>> | RecursiveRunOutput<TIntent, TOutput>;
|
||||
childMiddleware?: RecursiveChildMiddleware<TIntent>;
|
||||
}): Promise<TOutput> {
|
||||
return this.runRecursiveNode({
|
||||
sessionId: input.sessionId,
|
||||
depth: input.depth,
|
||||
signal: input.signal,
|
||||
run: input.run,
|
||||
childMiddleware: input.childMiddleware,
|
||||
});
|
||||
}
|
||||
|
||||
getLimits(): AgentManagerLimits {
|
||||
return { ...this.limits };
|
||||
}
|
||||
|
||||
getActiveAgentCount(): number {
|
||||
return this.activeAgents;
|
||||
}
|
||||
|
||||
private assertDepth(depth: number): void {
|
||||
if (!Number.isInteger(depth) || depth < 0) {
|
||||
throw new Error("Agent depth must be a non-negative integer.");
|
||||
}
|
||||
if (depth > this.limits.maxRecursiveDepth) {
|
||||
throw new Error(
|
||||
`Agent depth ${depth} exceeds maxRecursiveDepth ${this.limits.maxRecursiveDepth}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private canAcquire(session: SessionState): boolean {
|
||||
return (
|
||||
this.activeAgents < this.limits.maxConcurrentAgents &&
|
||||
session.activeAgents < this.limits.maxSessionAgents
|
||||
);
|
||||
}
|
||||
|
||||
private acquire(sessionId: string, depth: number, signal?: AbortSignal): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || session.closed) {
|
||||
throw new Error(`Agent session "${sessionId}" is not active.`);
|
||||
}
|
||||
|
||||
this.assertDepth(depth);
|
||||
throwIfAborted(signal);
|
||||
|
||||
if (this.canAcquire(session)) {
|
||||
this.activeAgents += 1;
|
||||
session.activeAgents += 1;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.waiters.push({
|
||||
sessionId,
|
||||
depth,
|
||||
signal,
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
|
||||
if (!signal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const waiter = this.waiters[this.waiters.length - 1];
|
||||
if (!waiter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
const index = this.waiters.indexOf(waiter);
|
||||
if (index >= 0) {
|
||||
this.waiters.splice(index, 1);
|
||||
waiter.reject(toAbortError(signal.reason));
|
||||
}
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
waiter.cleanupSignalListener = () => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private release(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeAgents = Math.max(this.activeAgents - 1, 0);
|
||||
session.activeAgents = Math.max(session.activeAgents - 1, 0);
|
||||
|
||||
if (session.closed && session.activeAgents === 0) {
|
||||
if (session.childSessionIds.size === 0) {
|
||||
this.deleteSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
this.drainWaiters();
|
||||
}
|
||||
|
||||
private rejectWaitersForSession(sessionId: string): void {
|
||||
for (let index = 0; index < this.waiters.length; ) {
|
||||
const waiter = this.waiters[index];
|
||||
if (waiter?.sessionId !== sessionId) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
this.waiters.splice(index, 1);
|
||||
waiter.cleanupSignalListener?.();
|
||||
waiter.reject(new Error(`Agent session "${sessionId}" was closed.`));
|
||||
}
|
||||
}
|
||||
|
||||
private drainWaiters(): void {
|
||||
for (let index = 0; index < this.waiters.length; ) {
|
||||
const waiter = this.waiters[index];
|
||||
if (!waiter) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (waiter.signal?.aborted) {
|
||||
this.waiters.splice(index, 1);
|
||||
waiter.cleanupSignalListener?.();
|
||||
waiter.reject(toAbortError(waiter.signal.reason));
|
||||
continue;
|
||||
}
|
||||
|
||||
const session = this.sessions.get(waiter.sessionId);
|
||||
if (!session || session.closed) {
|
||||
this.waiters.splice(index, 1);
|
||||
waiter.cleanupSignalListener?.();
|
||||
waiter.reject(new Error(`Agent session "${waiter.sessionId}" is not active.`));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (waiter.depth > this.limits.maxRecursiveDepth) {
|
||||
this.waiters.splice(index, 1);
|
||||
waiter.cleanupSignalListener?.();
|
||||
waiter.reject(
|
||||
new Error(
|
||||
`Agent depth ${waiter.depth} exceeds maxRecursiveDepth ${this.limits.maxRecursiveDepth}.`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!this.canAcquire(session)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
this.activeAgents += 1;
|
||||
session.activeAgents += 1;
|
||||
this.waiters.splice(index, 1);
|
||||
waiter.cleanupSignalListener?.();
|
||||
waiter.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
private deleteSession(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentSessionId = session.parentSessionId;
|
||||
this.sessions.delete(sessionId);
|
||||
if (parentSessionId) {
|
||||
const parent = this.sessions.get(parentSessionId);
|
||||
parent?.childSessionIds.delete(sessionId);
|
||||
if (parent && parent.closed && parent.activeAgents === 0 && parent.childSessionIds.size === 0) {
|
||||
this.deleteSession(parentSessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getSessionSignal(sessionId: string): AbortSignal {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || session.closed) {
|
||||
throw new Error(`Agent session "${sessionId}" is not active.`);
|
||||
}
|
||||
return session.abortController.signal;
|
||||
}
|
||||
|
||||
private assertCanSpawnChild(depth: number): void {
|
||||
const childDepth = depth + 1;
|
||||
if (childDepth > this.limits.maxRecursiveDepth) {
|
||||
throw new Error(
|
||||
`Cannot spawn child at depth ${childDepth} because maxRecursiveDepth is ${this.limits.maxRecursiveDepth}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private buildChildSessionId(parentSessionId: string, childIndex: number): string {
|
||||
return `${parentSessionId}_child_${childIndex + 1}`;
|
||||
}
|
||||
|
||||
private async runRecursiveNode<TIntent extends RecursiveChildIntent, TOutput>(input: {
|
||||
sessionId: string;
|
||||
depth: number;
|
||||
signal?: AbortSignal;
|
||||
run: (
|
||||
input: RecursiveRunContext<TIntent>,
|
||||
) => Promise<RecursiveRunOutput<TIntent, TOutput>> | RecursiveRunOutput<TIntent, TOutput>;
|
||||
childMiddleware?: RecursiveChildMiddleware<TIntent>;
|
||||
parentSessionId?: string;
|
||||
intent?: TIntent;
|
||||
}): Promise<TOutput> {
|
||||
const sessionSignal = this.getSessionSignal(input.sessionId);
|
||||
const linkedSignal = createLinkedAbortSignal([input.signal, sessionSignal]);
|
||||
|
||||
try {
|
||||
throwIfAborted(linkedSignal.signal);
|
||||
|
||||
const nodeResultRaw = await this.runInSession({
|
||||
sessionId: input.sessionId,
|
||||
depth: input.depth,
|
||||
signal: linkedSignal.signal,
|
||||
run: () =>
|
||||
input.run({
|
||||
sessionId: input.sessionId,
|
||||
depth: input.depth,
|
||||
signal: linkedSignal.signal,
|
||||
...(input.parentSessionId ? { parentSessionId: input.parentSessionId } : {}),
|
||||
...(input.intent !== undefined ? { intent: input.intent } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
const nodeResult = isRecursiveFanoutPlan<TIntent, TOutput>(nodeResultRaw)
|
||||
? nodeResultRaw
|
||||
: isRecursiveCompleteResult<TOutput>(nodeResultRaw)
|
||||
? nodeResultRaw
|
||||
: toRecursiveComplete(nodeResultRaw);
|
||||
|
||||
if (nodeResult.type === "complete") {
|
||||
return nodeResult.output;
|
||||
}
|
||||
|
||||
this.assertCanSpawnChild(input.depth);
|
||||
const childDepth = input.depth + 1;
|
||||
|
||||
const fanoutAbortController = new AbortController();
|
||||
const fanoutSignal = createLinkedAbortSignal([linkedSignal.signal, fanoutAbortController.signal]);
|
||||
|
||||
try {
|
||||
const childRuns = nodeResult.intents.map((intent, childIndex) =>
|
||||
this.runRecursiveChild({
|
||||
parentSessionId: input.sessionId,
|
||||
childDepth,
|
||||
childIndex,
|
||||
childCount: nodeResult.intents.length,
|
||||
intent,
|
||||
signal: fanoutSignal.signal,
|
||||
run: input.run,
|
||||
childMiddleware: input.childMiddleware,
|
||||
}).catch((error: unknown) => {
|
||||
if (!fanoutAbortController.signal.aborted) {
|
||||
fanoutAbortController.abort(error);
|
||||
}
|
||||
throw error;
|
||||
}),
|
||||
);
|
||||
|
||||
const settledChildren = await Promise.allSettled(childRuns);
|
||||
const rejected = settledChildren.find(
|
||||
(entry): entry is PromiseRejectedResult => entry.status === "rejected",
|
||||
);
|
||||
if (rejected) {
|
||||
throw toError(rejected.reason);
|
||||
}
|
||||
|
||||
const childResults = settledChildren
|
||||
.filter(
|
||||
(entry): entry is PromiseFulfilledResult<RecursiveChildResult<TIntent, TOutput>> =>
|
||||
entry.status === "fulfilled",
|
||||
)
|
||||
.map((entry) => entry.value);
|
||||
|
||||
return this.runInSession({
|
||||
sessionId: input.sessionId,
|
||||
depth: input.depth,
|
||||
signal: linkedSignal.signal,
|
||||
run: () =>
|
||||
nodeResult.aggregate({
|
||||
sessionId: input.sessionId,
|
||||
depth: input.depth,
|
||||
signal: linkedSignal.signal,
|
||||
childResults,
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
fanoutSignal.cleanup();
|
||||
}
|
||||
} finally {
|
||||
linkedSignal.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private async runRecursiveChild<TIntent extends RecursiveChildIntent, TOutput>(input: {
|
||||
parentSessionId: string;
|
||||
childDepth: number;
|
||||
childIndex: number;
|
||||
childCount: number;
|
||||
intent: TIntent;
|
||||
signal: AbortSignal;
|
||||
run: (
|
||||
input: RecursiveRunContext<TIntent>,
|
||||
) => Promise<RecursiveRunOutput<TIntent, TOutput>> | RecursiveRunOutput<TIntent, TOutput>;
|
||||
childMiddleware?: RecursiveChildMiddleware<TIntent>;
|
||||
}): Promise<RecursiveChildResult<TIntent, TOutput>> {
|
||||
const childSessionId = this.buildChildSessionId(input.parentSessionId, input.childIndex);
|
||||
const childSession = this.createSession(childSessionId, {
|
||||
parentSessionId: input.parentSessionId,
|
||||
});
|
||||
let childOutput: TOutput | undefined;
|
||||
let childError: Error | undefined;
|
||||
|
||||
try {
|
||||
await input.childMiddleware?.allocateForChild?.({
|
||||
parentSessionId: input.parentSessionId,
|
||||
childSessionId,
|
||||
depth: input.childDepth,
|
||||
childIndex: input.childIndex,
|
||||
childCount: input.childCount,
|
||||
intent: input.intent,
|
||||
signal: input.signal,
|
||||
});
|
||||
|
||||
childOutput = await this.runRecursiveNode({
|
||||
sessionId: childSessionId,
|
||||
depth: input.childDepth,
|
||||
signal: input.signal,
|
||||
run: input.run,
|
||||
childMiddleware: input.childMiddleware,
|
||||
parentSessionId: input.parentSessionId,
|
||||
intent: input.intent,
|
||||
});
|
||||
} catch (error) {
|
||||
childError = toError(error);
|
||||
}
|
||||
|
||||
let releaseError: Error | undefined;
|
||||
try {
|
||||
await input.childMiddleware?.releaseForChild?.({
|
||||
parentSessionId: input.parentSessionId,
|
||||
childSessionId,
|
||||
depth: input.childDepth,
|
||||
childIndex: input.childIndex,
|
||||
childCount: input.childCount,
|
||||
intent: input.intent,
|
||||
signal: input.signal,
|
||||
outcome: childError ? { status: "failure", error: childError } : { status: "success" },
|
||||
});
|
||||
} catch (error) {
|
||||
releaseError = toError(error);
|
||||
} finally {
|
||||
childSession.close();
|
||||
}
|
||||
|
||||
if (releaseError) {
|
||||
throw releaseError;
|
||||
}
|
||||
if (childError) {
|
||||
throw childError;
|
||||
}
|
||||
if (childOutput === undefined) {
|
||||
throw new Error(`Child session "${childSessionId}" completed without an output.`);
|
||||
}
|
||||
|
||||
return {
|
||||
childSessionId,
|
||||
intent: input.intent,
|
||||
output: childOutput,
|
||||
};
|
||||
}
|
||||
}
|
||||
506
src/agents/manifest.ts
Normal file
506
src/agents/manifest.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
import { isRecord } from "./types.js";
|
||||
|
||||
export type ToolClearancePolicy = {
|
||||
allowlist: string[];
|
||||
banlist: string[];
|
||||
};
|
||||
|
||||
export type ManifestPersona = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
systemPromptTemplate: string;
|
||||
toolClearance: ToolClearancePolicy;
|
||||
};
|
||||
|
||||
export type RelationshipConstraint = {
|
||||
maxDepth?: number;
|
||||
maxChildren?: number;
|
||||
};
|
||||
|
||||
export type RelationshipEdge = {
|
||||
parentPersonaId: string;
|
||||
childPersonaId: string;
|
||||
constraints?: RelationshipConstraint;
|
||||
};
|
||||
|
||||
export type RouteCondition =
|
||||
| {
|
||||
type: "always";
|
||||
}
|
||||
| {
|
||||
type: "state_flag";
|
||||
key: string;
|
||||
equals: boolean;
|
||||
}
|
||||
| {
|
||||
type: "history_has_event";
|
||||
event: string;
|
||||
}
|
||||
| {
|
||||
type: "file_exists";
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type PipelineConstraint = {
|
||||
maxRetries?: number;
|
||||
};
|
||||
|
||||
export type PipelineNode = {
|
||||
id: string;
|
||||
actorId: string;
|
||||
personaId: string;
|
||||
constraints?: PipelineConstraint;
|
||||
};
|
||||
|
||||
export type PipelineEdge = {
|
||||
from: string;
|
||||
to: string;
|
||||
on:
|
||||
| "success"
|
||||
| "validation_fail"
|
||||
| "failure"
|
||||
| "always"
|
||||
| "onTaskComplete"
|
||||
| "onValidationFail";
|
||||
when?: RouteCondition[];
|
||||
};
|
||||
|
||||
export type PipelineGraph = {
|
||||
entryNodeId: string;
|
||||
nodes: PipelineNode[];
|
||||
edges: PipelineEdge[];
|
||||
};
|
||||
|
||||
export type TopologyKind = "hierarchical" | "retry-unrolled" | "sequential";
|
||||
|
||||
export type TopologyConstraint = {
|
||||
maxDepth: number;
|
||||
maxRetries: number;
|
||||
};
|
||||
|
||||
export type AgentManifest = {
|
||||
schemaVersion: "1";
|
||||
topologies: TopologyKind[];
|
||||
personas: ManifestPersona[];
|
||||
relationships: RelationshipEdge[];
|
||||
pipeline: PipelineGraph;
|
||||
topologyConstraints: TopologyConstraint;
|
||||
};
|
||||
|
||||
function readString(record: Record<string, unknown>, key: string): string {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`Manifest field \"${key}\" must be a non-empty string.`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function readOptionalInteger(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
input: {
|
||||
min: number;
|
||||
},
|
||||
): number | undefined {
|
||||
const value = record[key];
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== "number" || !Number.isInteger(value) || value < input.min) {
|
||||
throw new Error(`Manifest field \"${key}\" must be an integer >= ${String(input.min)}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readStringArray(record: Record<string, unknown>, key: string): string[] {
|
||||
const value = record[key];
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`Manifest field \"${key}\" must be an array.`);
|
||||
}
|
||||
|
||||
const output: string[] = [];
|
||||
for (const item of value) {
|
||||
if (typeof item !== "string" || item.trim().length === 0) {
|
||||
throw new Error(`Manifest field \"${key}\" contains an invalid string.`);
|
||||
}
|
||||
output.push(item.trim());
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function parseToolClearance(value: unknown): ToolClearancePolicy {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Manifest persona toolClearance must be an object.");
|
||||
}
|
||||
|
||||
return {
|
||||
allowlist: readStringArray(value, "allowlist"),
|
||||
banlist: readStringArray(value, "banlist"),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePersona(value: unknown): ManifestPersona {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Manifest persona entry must be an object.");
|
||||
}
|
||||
|
||||
return {
|
||||
id: readString(value, "id"),
|
||||
displayName: readString(value, "displayName"),
|
||||
systemPromptTemplate: readString(value, "systemPromptTemplate"),
|
||||
toolClearance: parseToolClearance(value.toolClearance),
|
||||
};
|
||||
}
|
||||
|
||||
function parseRelationship(value: unknown): RelationshipEdge {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Manifest relationship entry must be an object.");
|
||||
}
|
||||
|
||||
const constraints = isRecord(value.constraints)
|
||||
? {
|
||||
maxDepth: readOptionalInteger(value.constraints, "maxDepth", { min: 1 }),
|
||||
maxChildren: readOptionalInteger(value.constraints, "maxChildren", { min: 1 }),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
parentPersonaId: readString(value, "parentPersonaId"),
|
||||
childPersonaId: readString(value, "childPersonaId"),
|
||||
constraints,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCondition(value: unknown): RouteCondition {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Route condition must be an object.");
|
||||
}
|
||||
|
||||
const type = readString(value, "type");
|
||||
if (type === "always") {
|
||||
return { type };
|
||||
}
|
||||
|
||||
if (type === "state_flag") {
|
||||
const key = readString(value, "key");
|
||||
const equals = value.equals;
|
||||
if (typeof equals !== "boolean") {
|
||||
throw new Error('Route condition field "equals" must be a boolean.');
|
||||
}
|
||||
return {
|
||||
type,
|
||||
key,
|
||||
equals,
|
||||
};
|
||||
}
|
||||
|
||||
if (type === "history_has_event") {
|
||||
return {
|
||||
type,
|
||||
event: readString(value, "event"),
|
||||
};
|
||||
}
|
||||
|
||||
if (type === "file_exists") {
|
||||
return {
|
||||
type,
|
||||
path: readString(value, "path"),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported route condition type \"${type}\".`);
|
||||
}
|
||||
|
||||
function parsePipelineNode(value: unknown): PipelineNode {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Pipeline node must be an object.");
|
||||
}
|
||||
|
||||
const constraints = isRecord(value.constraints)
|
||||
? {
|
||||
maxRetries: readOptionalInteger(value.constraints, "maxRetries", { min: 0 }),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: readString(value, "id"),
|
||||
actorId: readString(value, "actorId"),
|
||||
personaId: readString(value, "personaId"),
|
||||
constraints,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePipelineEdge(value: unknown): PipelineEdge {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Pipeline edge must be an object.");
|
||||
}
|
||||
|
||||
const on = readString(value, "on");
|
||||
const validEvents: PipelineEdge["on"][] = [
|
||||
"success",
|
||||
"validation_fail",
|
||||
"failure",
|
||||
"always",
|
||||
"onTaskComplete",
|
||||
"onValidationFail",
|
||||
];
|
||||
|
||||
if (!validEvents.includes(on as PipelineEdge["on"])) {
|
||||
throw new Error(`Pipeline edge field \"on\" has unsupported event \"${on}\".`);
|
||||
}
|
||||
|
||||
const rawWhen = value.when;
|
||||
const when: RouteCondition[] = [];
|
||||
if (rawWhen !== undefined) {
|
||||
if (!Array.isArray(rawWhen)) {
|
||||
throw new Error('Pipeline edge field "when" must be an array when provided.');
|
||||
}
|
||||
for (const condition of rawWhen) {
|
||||
when.push(parseCondition(condition));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
from: readString(value, "from"),
|
||||
to: readString(value, "to"),
|
||||
on: on as PipelineEdge["on"],
|
||||
...(when.length > 0 ? { when } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePipeline(value: unknown): PipelineGraph {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Manifest pipeline must be an object.");
|
||||
}
|
||||
|
||||
const nodesValue = value.nodes;
|
||||
if (!Array.isArray(nodesValue) || nodesValue.length === 0) {
|
||||
throw new Error("Manifest pipeline.nodes must be a non-empty array.");
|
||||
}
|
||||
|
||||
const edgesValue = value.edges;
|
||||
if (!Array.isArray(edgesValue)) {
|
||||
throw new Error("Manifest pipeline.edges must be an array.");
|
||||
}
|
||||
|
||||
const nodes = nodesValue.map(parsePipelineNode);
|
||||
const edges = edgesValue.map(parsePipelineEdge);
|
||||
|
||||
return {
|
||||
entryNodeId: readString(value, "entryNodeId"),
|
||||
nodes,
|
||||
edges,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTopologies(value: unknown): TopologyKind[] {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
throw new Error("Manifest topologies must be a non-empty array.");
|
||||
}
|
||||
|
||||
const valid = new Set<TopologyKind>(["hierarchical", "retry-unrolled", "sequential"]);
|
||||
const result: TopologyKind[] = [];
|
||||
|
||||
for (const item of value) {
|
||||
if (typeof item !== "string" || !valid.has(item as TopologyKind)) {
|
||||
throw new Error("Manifest topologies contains an unsupported topology kind.");
|
||||
}
|
||||
result.push(item as TopologyKind);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseTopologyConstraints(value: unknown): TopologyConstraint {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Manifest topologyConstraints must be an object.");
|
||||
}
|
||||
|
||||
const maxDepth = readOptionalInteger(value, "maxDepth", { min: 1 });
|
||||
const maxRetries = readOptionalInteger(value, "maxRetries", { min: 0 });
|
||||
|
||||
return {
|
||||
maxDepth: maxDepth ?? 4,
|
||||
maxRetries: maxRetries ?? 2,
|
||||
};
|
||||
}
|
||||
|
||||
function assertNoDuplicates(items: string[], label: string): void {
|
||||
const seen = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (seen.has(item)) {
|
||||
throw new Error(`${label} contains duplicate id \"${item}\".`);
|
||||
}
|
||||
seen.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPipelineDag(pipeline: PipelineGraph): void {
|
||||
const adjacency = new Map<string, string[]>();
|
||||
const indegree = new Map<string, number>();
|
||||
const nodeIds = new Set<string>(pipeline.nodes.map((node) => node.id));
|
||||
|
||||
for (const node of pipeline.nodes) {
|
||||
adjacency.set(node.id, []);
|
||||
indegree.set(node.id, 0);
|
||||
}
|
||||
|
||||
if (!nodeIds.has(pipeline.entryNodeId)) {
|
||||
throw new Error(`Pipeline entry node \"${pipeline.entryNodeId}\" is not defined.`);
|
||||
}
|
||||
|
||||
for (const edge of pipeline.edges) {
|
||||
if (!nodeIds.has(edge.from)) {
|
||||
throw new Error(`Pipeline edge references unknown from node \"${edge.from}\".`);
|
||||
}
|
||||
if (!nodeIds.has(edge.to)) {
|
||||
throw new Error(`Pipeline edge references unknown to node \"${edge.to}\".`);
|
||||
}
|
||||
|
||||
const neighbors = adjacency.get(edge.from);
|
||||
if (!neighbors) {
|
||||
throw new Error(`Internal DAG error for node \"${edge.from}\".`);
|
||||
}
|
||||
neighbors.push(edge.to);
|
||||
const currentInDegree = indegree.get(edge.to);
|
||||
indegree.set(edge.to, (currentInDegree ?? 0) + 1);
|
||||
}
|
||||
|
||||
const queue: string[] = [];
|
||||
for (const [nodeId, degree] of indegree.entries()) {
|
||||
if (degree === 0) {
|
||||
queue.push(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
let visited = 0;
|
||||
for (let cursor = 0; cursor < queue.length; cursor += 1) {
|
||||
const current = queue[cursor];
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
visited += 1;
|
||||
|
||||
const neighbors = adjacency.get(current) ?? [];
|
||||
for (const neighbor of neighbors) {
|
||||
const degree = indegree.get(neighbor);
|
||||
if (degree === undefined) {
|
||||
continue;
|
||||
}
|
||||
const nextDegree = degree - 1;
|
||||
indegree.set(neighbor, nextDegree);
|
||||
if (nextDegree === 0) {
|
||||
queue.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visited !== pipeline.nodes.length) {
|
||||
throw new Error("Pipeline graph must be a strict DAG (cycle detected).");
|
||||
}
|
||||
}
|
||||
|
||||
function assertRelationshipDag(relationships: RelationshipEdge[]): void {
|
||||
const adjacency = new Map<string, string[]>();
|
||||
|
||||
for (const relationship of relationships) {
|
||||
const children = adjacency.get(relationship.parentPersonaId);
|
||||
if (children) {
|
||||
children.push(relationship.childPersonaId);
|
||||
} else {
|
||||
adjacency.set(relationship.parentPersonaId, [relationship.childPersonaId]);
|
||||
}
|
||||
if (!adjacency.has(relationship.childPersonaId)) {
|
||||
adjacency.set(relationship.childPersonaId, []);
|
||||
}
|
||||
}
|
||||
|
||||
const visiting = new Set<string>();
|
||||
const visited = new Set<string>();
|
||||
|
||||
const visit = (nodeId: string): void => {
|
||||
if (visiting.has(nodeId)) {
|
||||
throw new Error("Relationship graph must be acyclic (cycle detected).");
|
||||
}
|
||||
if (visited.has(nodeId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
visiting.add(nodeId);
|
||||
for (const childId of adjacency.get(nodeId) ?? []) {
|
||||
visit(childId);
|
||||
}
|
||||
visiting.delete(nodeId);
|
||||
visited.add(nodeId);
|
||||
};
|
||||
|
||||
for (const nodeId of adjacency.keys()) {
|
||||
visit(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAgentManifest(input: unknown): AgentManifest {
|
||||
if (!isRecord(input)) {
|
||||
throw new Error("AgentManifest must be an object.");
|
||||
}
|
||||
|
||||
const schemaVersion = readString(input, "schemaVersion");
|
||||
if (schemaVersion !== "1") {
|
||||
throw new Error(`Unsupported AgentManifest schemaVersion \"${schemaVersion}\".`);
|
||||
}
|
||||
|
||||
const personasValue = input.personas;
|
||||
if (!Array.isArray(personasValue) || personasValue.length === 0) {
|
||||
throw new Error("Manifest personas must be a non-empty array.");
|
||||
}
|
||||
|
||||
const relationshipsValue = input.relationships;
|
||||
if (!Array.isArray(relationshipsValue)) {
|
||||
throw new Error("Manifest relationships must be an array.");
|
||||
}
|
||||
|
||||
const manifest: AgentManifest = {
|
||||
schemaVersion: "1",
|
||||
topologies: parseTopologies(input.topologies),
|
||||
personas: personasValue.map(parsePersona),
|
||||
relationships: relationshipsValue.map(parseRelationship),
|
||||
pipeline: parsePipeline(input.pipeline),
|
||||
topologyConstraints: parseTopologyConstraints(input.topologyConstraints),
|
||||
};
|
||||
|
||||
assertNoDuplicates(
|
||||
manifest.personas.map((persona) => persona.id),
|
||||
"Manifest personas",
|
||||
);
|
||||
assertNoDuplicates(
|
||||
manifest.pipeline.nodes.map((node) => node.id),
|
||||
"Manifest pipeline.nodes",
|
||||
);
|
||||
|
||||
const personaIds = new Set(manifest.personas.map((persona) => persona.id));
|
||||
|
||||
for (const relation of manifest.relationships) {
|
||||
if (!personaIds.has(relation.parentPersonaId)) {
|
||||
throw new Error(
|
||||
`Relationship references unknown parent persona \"${relation.parentPersonaId}\".`,
|
||||
);
|
||||
}
|
||||
if (!personaIds.has(relation.childPersonaId)) {
|
||||
throw new Error(
|
||||
`Relationship references unknown child persona \"${relation.childPersonaId}\".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assertRelationshipDag(manifest.relationships);
|
||||
|
||||
for (const node of manifest.pipeline.nodes) {
|
||||
if (!personaIds.has(node.personaId)) {
|
||||
throw new Error(`Pipeline node \"${node.id}\" references unknown persona \"${node.personaId}\".`);
|
||||
}
|
||||
}
|
||||
|
||||
assertPipelineDag(manifest.pipeline);
|
||||
|
||||
return manifest;
|
||||
}
|
||||
215
src/agents/orchestration.ts
Normal file
215
src/agents/orchestration.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { resolve } from "node:path";
|
||||
import { parseAgentManifest, type AgentManifest } from "./manifest.js";
|
||||
import {
|
||||
PersonaRegistry,
|
||||
type PersonaBehaviorEvent,
|
||||
type PersonaBehaviorHandler,
|
||||
} from "./persona-registry.js";
|
||||
import { PipelineExecutor, type ActorExecutor, type PipelineRunSummary } from "./pipeline.js";
|
||||
import { FileSystemStateContextManager, type StoredSessionState } from "./state-context.js";
|
||||
import type { JsonObject } from "./types.js";
|
||||
|
||||
export type OrchestrationSettings = {
|
||||
workspaceRoot: string;
|
||||
stateRoot: string;
|
||||
maxDepth: number;
|
||||
maxRetries: number;
|
||||
maxChildren: number;
|
||||
runtimeContext: Record<string, string | number | boolean>;
|
||||
};
|
||||
|
||||
export type BehaviorHandlerRegistry = Partial<
|
||||
Record<string, Partial<Record<PersonaBehaviorEvent, PersonaBehaviorHandler>>>
|
||||
>;
|
||||
|
||||
function readOptionalIntegerEnv(
|
||||
key:
|
||||
| "AGENT_TOPOLOGY_MAX_DEPTH"
|
||||
| "AGENT_TOPOLOGY_MAX_RETRIES"
|
||||
| "AGENT_RELATIONSHIP_MAX_CHILDREN",
|
||||
fallback: number,
|
||||
min: number,
|
||||
): number {
|
||||
const raw = process.env[key]?.trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isInteger(parsed) || parsed < min) {
|
||||
throw new Error(`Environment variable ${key} must be an integer >= ${String(min)}.`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readOptionalStringEnv(key: "AGENT_STATE_ROOT", fallback: string): string {
|
||||
const raw = process.env[key]?.trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function loadOrchestrationSettingsFromEnv(): Omit<
|
||||
OrchestrationSettings,
|
||||
"workspaceRoot" | "runtimeContext"
|
||||
> {
|
||||
return {
|
||||
stateRoot: readOptionalStringEnv("AGENT_STATE_ROOT", ".ai_ops/state"),
|
||||
maxDepth: readOptionalIntegerEnv("AGENT_TOPOLOGY_MAX_DEPTH", 4, 1),
|
||||
maxRetries: readOptionalIntegerEnv("AGENT_TOPOLOGY_MAX_RETRIES", 2, 0),
|
||||
maxChildren: readOptionalIntegerEnv("AGENT_RELATIONSHIP_MAX_CHILDREN", 4, 1),
|
||||
};
|
||||
}
|
||||
|
||||
function toExecutorMap(
|
||||
executors: ReadonlyMap<string, ActorExecutor> | Record<string, ActorExecutor>,
|
||||
): ReadonlyMap<string, ActorExecutor> {
|
||||
if (executors instanceof Map) {
|
||||
return executors;
|
||||
}
|
||||
return new Map(Object.entries(executors));
|
||||
}
|
||||
|
||||
function getChildrenByParent(manifest: AgentManifest): Map<string, AgentManifest["relationships"]> {
|
||||
const map = new Map<string, AgentManifest["relationships"]>();
|
||||
|
||||
for (const edge of manifest.relationships) {
|
||||
const current = map.get(edge.parentPersonaId);
|
||||
if (current) {
|
||||
current.push(edge);
|
||||
} else {
|
||||
map.set(edge.parentPersonaId, [edge]);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
export class SchemaDrivenExecutionEngine {
|
||||
private readonly manifest: AgentManifest;
|
||||
private readonly personaRegistry = new PersonaRegistry();
|
||||
private readonly stateManager: FileSystemStateContextManager;
|
||||
private readonly actorExecutors: ReadonlyMap<string, ActorExecutor>;
|
||||
private readonly settings: OrchestrationSettings;
|
||||
private readonly childrenByParent: Map<string, AgentManifest["relationships"]>;
|
||||
|
||||
constructor(input: {
|
||||
manifest: AgentManifest | unknown;
|
||||
actorExecutors: ReadonlyMap<string, ActorExecutor> | Record<string, ActorExecutor>;
|
||||
behaviorHandlers?: BehaviorHandlerRegistry;
|
||||
settings?: Partial<Omit<OrchestrationSettings, "workspaceRoot" | "runtimeContext">> & {
|
||||
workspaceRoot?: string;
|
||||
runtimeContext?: Record<string, string | number | boolean>;
|
||||
};
|
||||
}) {
|
||||
this.manifest = parseAgentManifest(input.manifest);
|
||||
|
||||
const defaults = loadOrchestrationSettingsFromEnv();
|
||||
this.settings = {
|
||||
workspaceRoot: resolve(input.settings?.workspaceRoot ?? process.cwd()),
|
||||
stateRoot: resolve(input.settings?.stateRoot ?? defaults.stateRoot),
|
||||
maxDepth: input.settings?.maxDepth ?? defaults.maxDepth,
|
||||
maxRetries: input.settings?.maxRetries ?? defaults.maxRetries,
|
||||
maxChildren: input.settings?.maxChildren ?? defaults.maxChildren,
|
||||
runtimeContext: {
|
||||
...(input.settings?.runtimeContext ?? {}),
|
||||
},
|
||||
};
|
||||
|
||||
this.stateManager = new FileSystemStateContextManager({
|
||||
rootDirectory: this.settings.stateRoot,
|
||||
});
|
||||
|
||||
this.actorExecutors = toExecutorMap(input.actorExecutors);
|
||||
|
||||
for (const persona of this.manifest.personas) {
|
||||
this.personaRegistry.register({
|
||||
...persona,
|
||||
behaviorHandlers: input.behaviorHandlers?.[persona.id],
|
||||
});
|
||||
}
|
||||
|
||||
this.childrenByParent = getChildrenByParent(this.manifest);
|
||||
this.assertRelationshipConstraints();
|
||||
}
|
||||
|
||||
getManifest(): AgentManifest {
|
||||
return this.manifest;
|
||||
}
|
||||
|
||||
getStateManager(): FileSystemStateContextManager {
|
||||
return this.stateManager;
|
||||
}
|
||||
|
||||
planChildPersonas(input: {
|
||||
parentPersonaId: string;
|
||||
depth: number;
|
||||
maxChildren?: number;
|
||||
}): string[] {
|
||||
if (input.depth > this.settings.maxDepth) {
|
||||
throw new Error(
|
||||
`Requested child planning depth ${String(input.depth)} exceeds configured maxDepth ${String(this.settings.maxDepth)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const relations = this.childrenByParent.get(input.parentPersonaId) ?? [];
|
||||
const effectiveLimit = input.maxChildren ?? this.settings.maxChildren;
|
||||
|
||||
const plannedChildren: string[] = [];
|
||||
|
||||
for (const relation of relations) {
|
||||
if (relation.constraints?.maxDepth !== undefined && input.depth > relation.constraints.maxDepth) {
|
||||
continue;
|
||||
}
|
||||
|
||||
plannedChildren.push(relation.childPersonaId);
|
||||
|
||||
const relationMax = relation.constraints?.maxChildren;
|
||||
if (relationMax !== undefined && plannedChildren.length >= relationMax) {
|
||||
break;
|
||||
}
|
||||
if (plannedChildren.length >= effectiveLimit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return plannedChildren;
|
||||
}
|
||||
|
||||
async runSession(input: {
|
||||
sessionId: string;
|
||||
initialPayload: JsonObject;
|
||||
initialState?: Partial<StoredSessionState>;
|
||||
}): Promise<PipelineRunSummary> {
|
||||
const executor = new PipelineExecutor(
|
||||
this.manifest,
|
||||
this.personaRegistry,
|
||||
this.stateManager,
|
||||
this.actorExecutors,
|
||||
{
|
||||
workspaceRoot: this.settings.workspaceRoot,
|
||||
runtimeContext: this.settings.runtimeContext,
|
||||
maxDepth: Math.min(this.settings.maxDepth, this.manifest.topologyConstraints.maxDepth),
|
||||
maxRetries: Math.min(this.settings.maxRetries, this.manifest.topologyConstraints.maxRetries),
|
||||
},
|
||||
);
|
||||
|
||||
return executor.run({
|
||||
sessionId: input.sessionId,
|
||||
initialPayload: input.initialPayload,
|
||||
initialState: input.initialState,
|
||||
});
|
||||
}
|
||||
|
||||
private assertRelationshipConstraints(): void {
|
||||
for (const [parent, edges] of this.childrenByParent.entries()) {
|
||||
if (edges.length > this.settings.maxChildren) {
|
||||
throw new Error(
|
||||
`Persona \"${parent}\" exceeds AGENT_RELATIONSHIP_MAX_CHILDREN (${String(this.settings.maxChildren)}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
112
src/agents/persona-registry.ts
Normal file
112
src/agents/persona-registry.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { JsonObject } from "./types.js";
|
||||
import type { ManifestPersona, ToolClearancePolicy } from "./manifest.js";
|
||||
|
||||
export type PersonaBehaviorEvent = "onTaskComplete" | "onValidationFail";
|
||||
|
||||
export type PersonaBehaviorContext = {
|
||||
event: PersonaBehaviorEvent;
|
||||
sessionId: string;
|
||||
nodeId: string;
|
||||
payload: JsonObject;
|
||||
};
|
||||
|
||||
export type PersonaBehaviorHandler = (
|
||||
context: PersonaBehaviorContext,
|
||||
) => Promise<void | JsonObject> | void | JsonObject;
|
||||
|
||||
export type PersonaRuntimeDefinition = ManifestPersona & {
|
||||
behaviorHandlers?: Partial<Record<PersonaBehaviorEvent, PersonaBehaviorHandler>>;
|
||||
};
|
||||
|
||||
function renderTemplate(
|
||||
template: string,
|
||||
runtimeContext: Record<string, string | number | boolean>,
|
||||
): string {
|
||||
return template.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (_, key: string) => {
|
||||
const value = runtimeContext[key];
|
||||
return value === undefined ? "" : String(value);
|
||||
});
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
const output: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const value of values) {
|
||||
if (!seen.has(value)) {
|
||||
output.push(value);
|
||||
seen.add(value);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export class PersonaRegistry {
|
||||
private readonly personas = new Map<string, PersonaRuntimeDefinition>();
|
||||
|
||||
registerMany(personas: PersonaRuntimeDefinition[]): void {
|
||||
for (const persona of personas) {
|
||||
this.register(persona);
|
||||
}
|
||||
}
|
||||
|
||||
register(persona: PersonaRuntimeDefinition): void {
|
||||
if (this.personas.has(persona.id)) {
|
||||
throw new Error(`Persona \"${persona.id}\" is already registered.`);
|
||||
}
|
||||
|
||||
this.personas.set(persona.id, {
|
||||
...persona,
|
||||
toolClearance: {
|
||||
allowlist: uniqueStrings(persona.toolClearance.allowlist),
|
||||
banlist: uniqueStrings(persona.toolClearance.banlist),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getById(personaId: string): PersonaRuntimeDefinition {
|
||||
const persona = this.personas.get(personaId);
|
||||
if (!persona) {
|
||||
throw new Error(`Persona \"${personaId}\" is not registered.`);
|
||||
}
|
||||
return persona;
|
||||
}
|
||||
|
||||
renderSystemPrompt(input: {
|
||||
personaId: string;
|
||||
runtimeContext: Record<string, string | number | boolean>;
|
||||
}): string {
|
||||
const persona = this.getById(input.personaId);
|
||||
return renderTemplate(persona.systemPromptTemplate, input.runtimeContext);
|
||||
}
|
||||
|
||||
getToolClearance(personaId: string): ToolClearancePolicy {
|
||||
const persona = this.getById(personaId);
|
||||
|
||||
// TODO(security): enforce allowlist/banlist in the tool execution boundary.
|
||||
return {
|
||||
allowlist: [...persona.toolClearance.allowlist],
|
||||
banlist: [...persona.toolClearance.banlist],
|
||||
};
|
||||
}
|
||||
|
||||
async emitBehaviorEvent(input: PersonaBehaviorContext & { personaId: string }): Promise<JsonObject> {
|
||||
const persona = this.getById(input.personaId);
|
||||
const handler = persona.behaviorHandlers?.[input.event];
|
||||
if (!handler) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result = await handler({
|
||||
event: input.event,
|
||||
sessionId: input.sessionId,
|
||||
nodeId: input.nodeId,
|
||||
payload: input.payload,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
314
src/agents/pipeline.ts
Normal file
314
src/agents/pipeline.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { access } from "node:fs/promises";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { AgentManifest, PipelineEdge, PipelineNode, RouteCondition } from "./manifest.js";
|
||||
import { PersonaRegistry } from "./persona-registry.js";
|
||||
import {
|
||||
FileSystemStateContextManager,
|
||||
type NodeExecutionContext,
|
||||
type SessionHistoryEntry,
|
||||
type StoredSessionState,
|
||||
} from "./state-context.js";
|
||||
import type { JsonObject } from "./types.js";
|
||||
|
||||
export type ActorResultStatus = "success" | "validation_fail" | "failure";
|
||||
|
||||
export type ActorExecutionResult = {
|
||||
status: ActorResultStatus;
|
||||
payload?: JsonObject;
|
||||
stateFlags?: Record<string, boolean>;
|
||||
stateMetadata?: JsonObject;
|
||||
};
|
||||
|
||||
export type ActorExecutionInput = {
|
||||
sessionId: string;
|
||||
node: PipelineNode;
|
||||
prompt: string;
|
||||
context: NodeExecutionContext;
|
||||
toolClearance: {
|
||||
allowlist: string[];
|
||||
banlist: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type ActorExecutor = (input: ActorExecutionInput) => Promise<ActorExecutionResult>;
|
||||
|
||||
export type PipelineExecutionRecord = {
|
||||
nodeId: string;
|
||||
depth: number;
|
||||
attempt: number;
|
||||
status: ActorResultStatus;
|
||||
};
|
||||
|
||||
export type PipelineRunSummary = {
|
||||
sessionId: string;
|
||||
records: PipelineExecutionRecord[];
|
||||
finalState: StoredSessionState;
|
||||
};
|
||||
|
||||
export type PipelineExecutorOptions = {
|
||||
workspaceRoot: string;
|
||||
runtimeContext: Record<string, string | number | boolean>;
|
||||
maxDepth: number;
|
||||
maxRetries: number;
|
||||
};
|
||||
|
||||
type QueueItem = {
|
||||
nodeId: string;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
function toBehaviorEvent(status: ActorResultStatus): "onTaskComplete" | "onValidationFail" | undefined {
|
||||
if (status === "success") {
|
||||
return "onTaskComplete";
|
||||
}
|
||||
if (status === "validation_fail") {
|
||||
return "onValidationFail";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function shouldEdgeRun(edge: PipelineEdge, status: ActorResultStatus): boolean {
|
||||
if (edge.on === "always") {
|
||||
return true;
|
||||
}
|
||||
if (edge.on === "success" && status === "success") {
|
||||
return true;
|
||||
}
|
||||
if (edge.on === "validation_fail" && status === "validation_fail") {
|
||||
return true;
|
||||
}
|
||||
if (edge.on === "failure" && status === "failure") {
|
||||
return true;
|
||||
}
|
||||
if (edge.on === "onTaskComplete" && status === "success") {
|
||||
return true;
|
||||
}
|
||||
if (edge.on === "onValidationFail" && status === "validation_fail") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function evaluateCondition(
|
||||
condition: RouteCondition,
|
||||
state: StoredSessionState,
|
||||
workspaceRoot: string,
|
||||
): Promise<boolean> {
|
||||
if (condition.type === "always") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (condition.type === "state_flag") {
|
||||
return state.flags[condition.key] === condition.equals;
|
||||
}
|
||||
|
||||
if (condition.type === "history_has_event") {
|
||||
return state.history.some((entry) => entry.event === condition.event);
|
||||
}
|
||||
|
||||
const absolutePath = resolve(workspaceRoot, condition.path);
|
||||
|
||||
try {
|
||||
await access(absolutePath, fsConstants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function edgeConditionsSatisfied(
|
||||
edge: PipelineEdge,
|
||||
state: StoredSessionState,
|
||||
workspaceRoot: string,
|
||||
): Promise<boolean> {
|
||||
if (!edge.when || edge.when.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const condition of edge.when) {
|
||||
const result = await evaluateCondition(condition, state, workspaceRoot);
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export class PipelineExecutor {
|
||||
private readonly nodeById = new Map<string, PipelineNode>();
|
||||
private readonly edgesBySource = new Map<string, PipelineEdge[]>();
|
||||
|
||||
constructor(
|
||||
private readonly manifest: AgentManifest,
|
||||
private readonly personaRegistry: PersonaRegistry,
|
||||
private readonly stateManager: FileSystemStateContextManager,
|
||||
private readonly actorExecutors: ReadonlyMap<string, ActorExecutor>,
|
||||
private readonly options: PipelineExecutorOptions,
|
||||
) {
|
||||
for (const node of manifest.pipeline.nodes) {
|
||||
this.nodeById.set(node.id, node);
|
||||
}
|
||||
|
||||
for (const edge of manifest.pipeline.edges) {
|
||||
const entries = this.edgesBySource.get(edge.from);
|
||||
if (entries) {
|
||||
entries.push(edge);
|
||||
} else {
|
||||
this.edgesBySource.set(edge.from, [edge]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async run(input: {
|
||||
sessionId: string;
|
||||
initialPayload: JsonObject;
|
||||
initialState?: Partial<StoredSessionState>;
|
||||
}): Promise<PipelineRunSummary> {
|
||||
await this.stateManager.initializeSession(input.sessionId, input.initialState);
|
||||
await this.stateManager.writeHandoff(input.sessionId, {
|
||||
nodeId: this.manifest.pipeline.entryNodeId,
|
||||
payload: input.initialPayload,
|
||||
});
|
||||
|
||||
const records: PipelineExecutionRecord[] = [];
|
||||
const queue: QueueItem[] = [{ nodeId: this.manifest.pipeline.entryNodeId, depth: 0 }];
|
||||
const attemptsByNode = new Map<string, number>();
|
||||
|
||||
const maxExecutions = this.manifest.pipeline.nodes.length * (this.options.maxRetries + 3);
|
||||
let executionCount = 0;
|
||||
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift();
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
executionCount += 1;
|
||||
if (executionCount > maxExecutions) {
|
||||
throw new Error("Pipeline execution exceeded the configured safe execution bound.");
|
||||
}
|
||||
|
||||
if (item.depth > this.options.maxDepth) {
|
||||
throw new Error(
|
||||
`Pipeline depth ${String(item.depth)} exceeds configured maxDepth ${String(this.options.maxDepth)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const node = this.nodeById.get(item.nodeId);
|
||||
if (!node) {
|
||||
throw new Error(`Pipeline node \"${item.nodeId}\" is not defined.`);
|
||||
}
|
||||
|
||||
const executor = this.actorExecutors.get(node.actorId);
|
||||
if (!executor) {
|
||||
throw new Error(`No actor executor registered for actor \"${node.actorId}\".`);
|
||||
}
|
||||
|
||||
const context = await this.stateManager.buildFreshNodeContext(input.sessionId, node.id);
|
||||
const prompt = this.personaRegistry.renderSystemPrompt({
|
||||
personaId: node.personaId,
|
||||
runtimeContext: {
|
||||
...this.options.runtimeContext,
|
||||
session_id: input.sessionId,
|
||||
node_id: node.id,
|
||||
depth: item.depth,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executor({
|
||||
sessionId: input.sessionId,
|
||||
node,
|
||||
prompt,
|
||||
context,
|
||||
toolClearance: this.personaRegistry.getToolClearance(node.personaId),
|
||||
});
|
||||
|
||||
const attempt = (attemptsByNode.get(node.id) ?? 0) + 1;
|
||||
attemptsByNode.set(node.id, attempt);
|
||||
records.push({
|
||||
nodeId: node.id,
|
||||
depth: item.depth,
|
||||
attempt,
|
||||
status: result.status,
|
||||
});
|
||||
|
||||
const behaviorEvent = toBehaviorEvent(result.status);
|
||||
const behaviorPatch = behaviorEvent
|
||||
? await this.personaRegistry.emitBehaviorEvent({
|
||||
personaId: node.personaId,
|
||||
event: behaviorEvent,
|
||||
sessionId: input.sessionId,
|
||||
nodeId: node.id,
|
||||
payload: result.payload ?? {},
|
||||
})
|
||||
: {};
|
||||
|
||||
const historyEvent: SessionHistoryEntry = {
|
||||
nodeId: node.id,
|
||||
event: result.status,
|
||||
timestamp: new Date().toISOString(),
|
||||
...(result.payload ? { data: result.payload } : {}),
|
||||
};
|
||||
|
||||
await this.stateManager.patchState(input.sessionId, {
|
||||
...(result.stateFlags ? { flags: result.stateFlags } : {}),
|
||||
metadata: {
|
||||
...(result.stateMetadata ?? {}),
|
||||
...behaviorPatch,
|
||||
},
|
||||
historyEvent,
|
||||
});
|
||||
|
||||
const maxRetriesForNode = Math.min(
|
||||
node.constraints?.maxRetries ?? this.manifest.topologyConstraints.maxRetries,
|
||||
this.options.maxRetries,
|
||||
);
|
||||
|
||||
if (result.status !== "success" && attempt <= maxRetriesForNode + 1) {
|
||||
await this.stateManager.writeHandoff(input.sessionId, {
|
||||
nodeId: node.id,
|
||||
fromNodeId: node.id,
|
||||
payload: result.payload ?? context.handoff.payload,
|
||||
});
|
||||
|
||||
queue.push({
|
||||
nodeId: node.id,
|
||||
depth: item.depth,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const state = await this.stateManager.readState(input.sessionId);
|
||||
const candidateEdges = this.edgesBySource.get(node.id) ?? [];
|
||||
|
||||
for (const edge of candidateEdges) {
|
||||
if (!shouldEdgeRun(edge, result.status)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(await edgeConditionsSatisfied(edge, state, this.options.workspaceRoot))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.stateManager.writeHandoff(input.sessionId, {
|
||||
nodeId: edge.to,
|
||||
fromNodeId: node.id,
|
||||
payload: result.payload ?? context.handoff.payload,
|
||||
});
|
||||
|
||||
queue.push({
|
||||
nodeId: edge.to,
|
||||
depth: item.depth + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: input.sessionId,
|
||||
records,
|
||||
finalState: await this.stateManager.readState(input.sessionId),
|
||||
};
|
||||
}
|
||||
}
|
||||
700
src/agents/provisioning.ts
Normal file
700
src/agents/provisioning.ts
Normal file
@@ -0,0 +1,700 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, open, unlink, writeFile } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
type JsonPrimitive = string | number | boolean | null;
|
||||
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
export type ResourceRequest = {
|
||||
kind: string;
|
||||
options?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DiscoverySnapshot = {
|
||||
sessionId: string;
|
||||
workspaceRoot: string;
|
||||
workingDirectory: string;
|
||||
hardConstraints: Array<{
|
||||
kind: string;
|
||||
allocation: Record<string, JsonValue>;
|
||||
}>;
|
||||
softConstraints: {
|
||||
env: Record<string, string>;
|
||||
promptSections: string[];
|
||||
metadata: Record<string, JsonValue>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ChildResourceSuballocationInput = {
|
||||
parentSnapshot: DiscoverySnapshot;
|
||||
childSessionId: string;
|
||||
childIndex: number;
|
||||
childCount: number;
|
||||
};
|
||||
|
||||
type ResourceContextPatch = {
|
||||
env?: Record<string, string>;
|
||||
promptSections?: string[];
|
||||
metadata?: Record<string, JsonValue>;
|
||||
preferredWorkingDirectory?: string;
|
||||
};
|
||||
|
||||
type ResourceLease = {
|
||||
kind: string;
|
||||
hard: Record<string, JsonValue>;
|
||||
soft?: ResourceContextPatch;
|
||||
release: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type ResourceProvider<Options extends Record<string, unknown> = Record<string, unknown>> = {
|
||||
kind: string;
|
||||
provision: (input: {
|
||||
sessionId: string;
|
||||
workspaceRoot: string;
|
||||
options: Options;
|
||||
}) => Promise<ResourceLease>;
|
||||
};
|
||||
|
||||
type RuntimeInjection = {
|
||||
workingDirectory: string;
|
||||
env: Record<string, string>;
|
||||
discoveryFilePath: string;
|
||||
};
|
||||
|
||||
type ProvisionedResourcesState = {
|
||||
sessionId: string;
|
||||
workspaceRoot: string;
|
||||
workingDirectory: string;
|
||||
hardConstraints: Array<{
|
||||
kind: string;
|
||||
allocation: Record<string, JsonValue>;
|
||||
}>;
|
||||
env: Record<string, string>;
|
||||
promptSections: string[];
|
||||
metadata: Record<string, JsonValue>;
|
||||
releases: Array<{
|
||||
kind: string;
|
||||
release: () => Promise<void>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export class ProvisionedResources {
|
||||
private released = false;
|
||||
|
||||
constructor(private readonly state: ProvisionedResourcesState) {}
|
||||
|
||||
getWorkingDirectory(): string {
|
||||
return this.state.workingDirectory;
|
||||
}
|
||||
|
||||
getInjectedEnv(baseEnv: Record<string, string | undefined> = process.env): Record<string, string> {
|
||||
return {
|
||||
...sanitizeEnv(baseEnv),
|
||||
...this.state.env,
|
||||
};
|
||||
}
|
||||
|
||||
composePrompt(prompt: string, extraPromptSections: string[] = []): string {
|
||||
const sections = [...this.state.promptSections, ...extraPromptSections];
|
||||
if (sections.length === 0) {
|
||||
return prompt;
|
||||
}
|
||||
|
||||
return [
|
||||
"Runtime resource constraints are already enforced by the orchestrator:",
|
||||
...sections.map((section) => `- ${section}`),
|
||||
"",
|
||||
prompt,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
toDiscoverySnapshot(): DiscoverySnapshot {
|
||||
return {
|
||||
sessionId: this.state.sessionId,
|
||||
workspaceRoot: this.state.workspaceRoot,
|
||||
workingDirectory: this.state.workingDirectory,
|
||||
hardConstraints: this.state.hardConstraints.map((entry) => ({
|
||||
kind: entry.kind,
|
||||
allocation: { ...entry.allocation },
|
||||
})),
|
||||
softConstraints: {
|
||||
env: { ...this.state.env },
|
||||
promptSections: [...this.state.promptSections],
|
||||
metadata: { ...this.state.metadata },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async buildRuntimeInjection(input?: {
|
||||
discoveryFileRelativePath?: string;
|
||||
baseEnv?: Record<string, string | undefined>;
|
||||
}): Promise<RuntimeInjection> {
|
||||
const relativePath = input?.discoveryFileRelativePath?.trim() || ".agent-context/resources.json";
|
||||
const discoveryFilePath = resolve(this.state.workingDirectory, relativePath);
|
||||
await mkdir(dirname(discoveryFilePath), { recursive: true });
|
||||
await writeFile(
|
||||
discoveryFilePath,
|
||||
`${JSON.stringify(this.toDiscoverySnapshot(), null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const env = this.getInjectedEnv(input?.baseEnv);
|
||||
env.AGENT_DISCOVERY_FILE = discoveryFilePath;
|
||||
|
||||
return {
|
||||
workingDirectory: this.state.workingDirectory,
|
||||
env,
|
||||
discoveryFilePath,
|
||||
};
|
||||
}
|
||||
|
||||
async release(): Promise<void> {
|
||||
if (this.released) {
|
||||
return;
|
||||
}
|
||||
this.released = true;
|
||||
|
||||
const errors: string[] = [];
|
||||
for (let index = this.state.releases.length - 1; index >= 0; index -= 1) {
|
||||
const releaser = this.state.releases[index];
|
||||
if (!releaser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await releaser.release();
|
||||
} catch (error) {
|
||||
errors.push(`${releaser.kind}: ${toErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Failed to release provisioned resources: ${errors.join(" | ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ResourceProvisioningOrchestrator {
|
||||
private readonly providers = new Map<string, ResourceProvider>();
|
||||
|
||||
constructor(providers: ResourceProvider[] = []) {
|
||||
for (const provider of providers) {
|
||||
this.registerProvider(provider);
|
||||
}
|
||||
}
|
||||
|
||||
registerProvider(provider: ResourceProvider): void {
|
||||
if (this.providers.has(provider.kind)) {
|
||||
throw new Error(`Resource provider "${provider.kind}" is already registered.`);
|
||||
}
|
||||
this.providers.set(provider.kind, provider);
|
||||
}
|
||||
|
||||
async provisionSession(input: {
|
||||
sessionId: string;
|
||||
resources: ResourceRequest[];
|
||||
workspaceRoot?: string;
|
||||
}): Promise<ProvisionedResources> {
|
||||
const workspaceRoot = resolve(input.workspaceRoot ?? process.cwd());
|
||||
const hardConstraints: ProvisionedResourcesState["hardConstraints"] = [];
|
||||
const releases: ProvisionedResourcesState["releases"] = [];
|
||||
const env: Record<string, string> = {};
|
||||
const promptSections: string[] = [];
|
||||
const metadata: Record<string, JsonValue> = {};
|
||||
let workingDirectory = workspaceRoot;
|
||||
|
||||
try {
|
||||
for (const resource of input.resources) {
|
||||
const provider = this.providers.get(resource.kind);
|
||||
if (!provider) {
|
||||
throw new Error(`No provider registered for resource kind "${resource.kind}".`);
|
||||
}
|
||||
|
||||
const lease = await provider.provision({
|
||||
sessionId: input.sessionId,
|
||||
workspaceRoot,
|
||||
options: (resource.options ?? {}) as Record<string, unknown>,
|
||||
});
|
||||
|
||||
hardConstraints.push({
|
||||
kind: lease.kind,
|
||||
allocation: lease.hard,
|
||||
});
|
||||
releases.push({
|
||||
kind: lease.kind,
|
||||
release: lease.release,
|
||||
});
|
||||
|
||||
if (lease.soft?.env) {
|
||||
Object.assign(env, lease.soft.env);
|
||||
}
|
||||
if (lease.soft?.promptSections) {
|
||||
promptSections.push(...lease.soft.promptSections);
|
||||
}
|
||||
if (lease.soft?.metadata) {
|
||||
Object.assign(metadata, lease.soft.metadata);
|
||||
}
|
||||
if (lease.soft?.preferredWorkingDirectory) {
|
||||
workingDirectory = resolve(lease.soft.preferredWorkingDirectory);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await releaseInReverse(releases);
|
||||
throw new Error(`Resource provisioning failed: ${toErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
return new ProvisionedResources({
|
||||
sessionId: input.sessionId,
|
||||
workspaceRoot,
|
||||
workingDirectory,
|
||||
hardConstraints,
|
||||
env,
|
||||
promptSections,
|
||||
metadata,
|
||||
releases,
|
||||
});
|
||||
}
|
||||
|
||||
async provisionChildSession(input: ChildResourceSuballocationInput): Promise<ProvisionedResources> {
|
||||
const childResources = buildChildResourceRequests(input);
|
||||
return this.provisionSession({
|
||||
sessionId: input.childSessionId,
|
||||
resources: childResources,
|
||||
workspaceRoot: input.parentSnapshot.workspaceRoot,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type GitWorktreeProviderConfig = {
|
||||
rootDirectory: string;
|
||||
baseRef: string;
|
||||
};
|
||||
|
||||
export type PortRangeProviderConfig = {
|
||||
basePort: number;
|
||||
blockSize: number;
|
||||
blockCount: number;
|
||||
primaryPortOffset: number;
|
||||
lockDirectory: string;
|
||||
};
|
||||
|
||||
export type BuiltInProvisioningConfig = {
|
||||
gitWorktree: GitWorktreeProviderConfig;
|
||||
portRange: PortRangeProviderConfig;
|
||||
};
|
||||
|
||||
export type BuiltInProvisioningConfigInput = {
|
||||
gitWorktree?: Partial<GitWorktreeProviderConfig>;
|
||||
portRange?: Partial<PortRangeProviderConfig>;
|
||||
};
|
||||
|
||||
export const DEFAULT_GIT_WORKTREE_CONFIG: GitWorktreeProviderConfig = {
|
||||
rootDirectory: ".ai_ops/worktrees",
|
||||
baseRef: "HEAD",
|
||||
};
|
||||
|
||||
export const DEFAULT_PORT_RANGE_CONFIG: PortRangeProviderConfig = {
|
||||
basePort: 36000,
|
||||
blockSize: 32,
|
||||
blockCount: 512,
|
||||
primaryPortOffset: 0,
|
||||
lockDirectory: ".ai_ops/locks/ports",
|
||||
};
|
||||
|
||||
export function createGitWorktreeProvider(
|
||||
config: GitWorktreeProviderConfig = DEFAULT_GIT_WORKTREE_CONFIG,
|
||||
): ResourceProvider {
|
||||
return {
|
||||
kind: "git-worktree",
|
||||
provision: async ({ sessionId, workspaceRoot, options }) => {
|
||||
const rootDirectory = readOptionalString(options, "rootDirectory", config.rootDirectory);
|
||||
const baseRef = readOptionalString(options, "baseRef", config.baseRef);
|
||||
|
||||
const repoRoot = await runGit(["-C", workspaceRoot, "rev-parse", "--show-toplevel"]);
|
||||
const worktreeRoot = resolvePath(repoRoot, rootDirectory);
|
||||
await mkdir(worktreeRoot, { recursive: true });
|
||||
|
||||
const worktreeName = buildScopedName(sessionId);
|
||||
const worktreePath = resolve(worktreeRoot, worktreeName);
|
||||
await runGit(["-C", repoRoot, "worktree", "add", "--detach", worktreePath, baseRef]);
|
||||
|
||||
return {
|
||||
kind: "git-worktree",
|
||||
hard: {
|
||||
repoRoot,
|
||||
worktreeRoot,
|
||||
worktreePath,
|
||||
baseRef,
|
||||
},
|
||||
soft: {
|
||||
env: {
|
||||
AGENT_REPO_ROOT: repoRoot,
|
||||
AGENT_WORKTREE_PATH: worktreePath,
|
||||
AGENT_WORKTREE_BASE_REF: baseRef,
|
||||
},
|
||||
promptSections: [
|
||||
`Git worktree: ${worktreePath}`,
|
||||
`Worktree base ref: ${baseRef}`,
|
||||
],
|
||||
metadata: {
|
||||
git_worktree_path: worktreePath,
|
||||
git_worktree_base_ref: baseRef,
|
||||
},
|
||||
preferredWorkingDirectory: worktreePath,
|
||||
},
|
||||
release: async () => {
|
||||
await runGit(["-C", repoRoot, "worktree", "remove", "--force", worktreePath]);
|
||||
await runGit(["-C", repoRoot, "worktree", "prune"]);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createPortRangeProvider(
|
||||
config: PortRangeProviderConfig = DEFAULT_PORT_RANGE_CONFIG,
|
||||
): ResourceProvider {
|
||||
return {
|
||||
kind: "port-range",
|
||||
provision: async ({ sessionId, workspaceRoot, options }) => {
|
||||
const basePort = readOptionalInteger(options, "basePort", config.basePort, { min: 1 });
|
||||
const blockSize = readOptionalInteger(options, "blockSize", config.blockSize, { min: 1 });
|
||||
const blockCount = readOptionalInteger(options, "blockCount", config.blockCount, {
|
||||
min: 1,
|
||||
});
|
||||
const primaryPortOffset = readOptionalInteger(
|
||||
options,
|
||||
"primaryPortOffset",
|
||||
config.primaryPortOffset,
|
||||
{ min: 0 },
|
||||
);
|
||||
const lockDirectory = readOptionalString(options, "lockDirectory", config.lockDirectory);
|
||||
|
||||
if (primaryPortOffset >= blockSize) {
|
||||
throw new Error("primaryPortOffset must be smaller than blockSize.");
|
||||
}
|
||||
|
||||
const maxPort = basePort + blockSize * blockCount - 1;
|
||||
if (maxPort > 65535) {
|
||||
throw new Error(
|
||||
`Port range exceeds 65535 (basePort=${basePort}, blockSize=${blockSize}, blockCount=${blockCount}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const lockRoot = resolvePath(workspaceRoot, lockDirectory);
|
||||
await mkdir(lockRoot, { recursive: true });
|
||||
|
||||
const seed = hashToUnsignedInt(sessionId);
|
||||
const preferredBlock = seed % blockCount;
|
||||
|
||||
let startPort = -1;
|
||||
let endPort = -1;
|
||||
let blockIndex = -1;
|
||||
let lockPath = "";
|
||||
|
||||
for (let offset = 0; offset < blockCount; offset += 1) {
|
||||
const candidateBlock = (preferredBlock + offset) % blockCount;
|
||||
const candidateStart = basePort + candidateBlock * blockSize;
|
||||
const candidateEnd = candidateStart + blockSize - 1;
|
||||
const candidateLockPath = resolve(
|
||||
lockRoot,
|
||||
`${String(candidateStart)}-${String(candidateEnd)}.lock`,
|
||||
);
|
||||
|
||||
const lockHandle = await tryCreateLockFile(candidateLockPath, {
|
||||
sessionId,
|
||||
allocatedAt: new Date().toISOString(),
|
||||
startPort: candidateStart,
|
||||
endPort: candidateEnd,
|
||||
});
|
||||
if (!lockHandle) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await lockHandle.close();
|
||||
startPort = candidateStart;
|
||||
endPort = candidateEnd;
|
||||
blockIndex = candidateBlock;
|
||||
lockPath = candidateLockPath;
|
||||
break;
|
||||
}
|
||||
|
||||
if (startPort < 0 || endPort < 0 || blockIndex < 0 || !lockPath) {
|
||||
throw new Error("No free deterministic port block is available.");
|
||||
}
|
||||
|
||||
const primaryPort = startPort + primaryPortOffset;
|
||||
|
||||
return {
|
||||
kind: "port-range",
|
||||
hard: {
|
||||
basePort,
|
||||
blockSize,
|
||||
blockCount,
|
||||
blockIndex,
|
||||
startPort,
|
||||
endPort,
|
||||
primaryPort,
|
||||
lockPath,
|
||||
},
|
||||
soft: {
|
||||
env: {
|
||||
AGENT_PORT_RANGE_START: String(startPort),
|
||||
AGENT_PORT_RANGE_END: String(endPort),
|
||||
AGENT_PORT_PRIMARY: String(primaryPort),
|
||||
},
|
||||
promptSections: [
|
||||
`Assigned deterministic port range: ${String(startPort)}-${String(endPort)}`,
|
||||
`Primary port: ${String(primaryPort)}`,
|
||||
],
|
||||
metadata: {
|
||||
port_range_start: startPort,
|
||||
port_range_end: endPort,
|
||||
port_primary: primaryPort,
|
||||
port_block_index: blockIndex,
|
||||
},
|
||||
},
|
||||
release: async () => {
|
||||
await unlink(lockPath);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultResourceProvisioningOrchestrator(
|
||||
input: BuiltInProvisioningConfigInput = {},
|
||||
): ResourceProvisioningOrchestrator {
|
||||
const orchestrator = new ResourceProvisioningOrchestrator();
|
||||
orchestrator.registerProvider(
|
||||
createGitWorktreeProvider({
|
||||
...DEFAULT_GIT_WORKTREE_CONFIG,
|
||||
...input.gitWorktree,
|
||||
}),
|
||||
);
|
||||
orchestrator.registerProvider(
|
||||
createPortRangeProvider({
|
||||
...DEFAULT_PORT_RANGE_CONFIG,
|
||||
...input.portRange,
|
||||
}),
|
||||
);
|
||||
return orchestrator;
|
||||
}
|
||||
|
||||
async function runGit(args: string[]): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("git", args, {
|
||||
encoding: "utf8",
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch (error) {
|
||||
throw new Error(`git ${args.join(" ")} failed: ${toErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildScopedName(sessionId: string): string {
|
||||
const safeSessionId = sessionId.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-");
|
||||
const base = safeSessionId || "session";
|
||||
const hash = createHash("sha256").update(sessionId).digest("hex").slice(0, 8);
|
||||
return `${base.slice(0, 32)}-${hash}`;
|
||||
}
|
||||
|
||||
function resolvePath(basePath: string, maybeRelativePath: string): string {
|
||||
if (isAbsolute(maybeRelativePath)) {
|
||||
return maybeRelativePath;
|
||||
}
|
||||
return resolve(basePath, maybeRelativePath);
|
||||
}
|
||||
|
||||
function sanitizeEnv(source: Record<string, string | undefined>): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (value !== undefined) {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
async function releaseInReverse(
|
||||
releases: Array<{
|
||||
kind: string;
|
||||
release: () => Promise<void>;
|
||||
}>,
|
||||
): Promise<void> {
|
||||
for (let index = releases.length - 1; index >= 0; index -= 1) {
|
||||
const releaser = releases[index];
|
||||
if (!releaser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await releaser.release();
|
||||
} catch {
|
||||
// Ignore rollback release errors to preserve the original provisioning error.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hashToUnsignedInt(value: string): number {
|
||||
const digest = createHash("sha256").update(value).digest();
|
||||
return digest.readUInt32BE(0);
|
||||
}
|
||||
|
||||
async function tryCreateLockFile(
|
||||
lockPath: string,
|
||||
payload: Record<string, JsonValue>,
|
||||
): Promise<Awaited<ReturnType<typeof open>> | undefined> {
|
||||
try {
|
||||
const handle = await open(lockPath, "wx");
|
||||
await handle.writeFile(`${JSON.stringify(payload)}\n`, "utf8");
|
||||
return handle;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
||||
return undefined;
|
||||
}
|
||||
throw new Error(`Failed to create lock file "${lockPath}": ${toErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function readOptionalString(
|
||||
options: Record<string, unknown>,
|
||||
key: string,
|
||||
fallback: string,
|
||||
): string {
|
||||
const value = options[key];
|
||||
if (value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`Option "${key}" must be a non-empty string.`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function readOptionalInteger(
|
||||
options: Record<string, unknown>,
|
||||
key: string,
|
||||
fallback: number,
|
||||
bounds: {
|
||||
min: number;
|
||||
},
|
||||
): number {
|
||||
const value = options[key];
|
||||
if (value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (typeof value !== "number" || !Number.isInteger(value) || value < bounds.min) {
|
||||
throw new Error(`Option "${key}" must be an integer >= ${String(bounds.min)}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readNumberFromAllocation(allocation: Record<string, JsonValue>, key: string): number {
|
||||
const value = allocation[key];
|
||||
if (typeof value !== "number" || !Number.isInteger(value)) {
|
||||
throw new Error(`Allocation field "${key}" must be an integer.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readStringFromAllocation(allocation: Record<string, JsonValue>, key: string): string {
|
||||
const value = allocation[key];
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`Allocation field "${key}" must be a non-empty string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getHardConstraint(
|
||||
snapshot: DiscoverySnapshot,
|
||||
kind: string,
|
||||
): Record<string, JsonValue> | undefined {
|
||||
for (const constraint of snapshot.hardConstraints) {
|
||||
if (constraint.kind === kind) {
|
||||
return constraint.allocation;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
export function buildChildResourceRequests(input: ChildResourceSuballocationInput): ResourceRequest[] {
|
||||
if (!Number.isInteger(input.childCount) || input.childCount < 1) {
|
||||
throw new Error("childCount must be a positive integer.");
|
||||
}
|
||||
if (!Number.isInteger(input.childIndex) || input.childIndex < 0 || input.childIndex >= input.childCount) {
|
||||
throw new Error("childIndex must be an integer in [0, childCount).");
|
||||
}
|
||||
|
||||
const requests: ResourceRequest[] = [];
|
||||
|
||||
const parentGit = getHardConstraint(input.parentSnapshot, "git-worktree");
|
||||
if (parentGit) {
|
||||
const parentWorktreePath = readStringFromAllocation(parentGit, "worktreePath");
|
||||
const baseRefRaw = parentGit.baseRef;
|
||||
const baseRef = typeof baseRefRaw === "string" && baseRefRaw.trim().length > 0 ? baseRefRaw : "HEAD";
|
||||
|
||||
requests.push({
|
||||
kind: "git-worktree",
|
||||
options: {
|
||||
rootDirectory: resolve(
|
||||
parentWorktreePath,
|
||||
".ai_ops/child-worktrees",
|
||||
buildScopedName(input.parentSnapshot.sessionId),
|
||||
),
|
||||
baseRef,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const parentPortRange = getHardConstraint(input.parentSnapshot, "port-range");
|
||||
if (parentPortRange) {
|
||||
const parentStart = readNumberFromAllocation(parentPortRange, "startPort");
|
||||
const parentEnd = readNumberFromAllocation(parentPortRange, "endPort");
|
||||
const parentPrimary = readNumberFromAllocation(parentPortRange, "primaryPort");
|
||||
const lockPath = readStringFromAllocation(parentPortRange, "lockPath");
|
||||
|
||||
const parentSize = parentEnd - parentStart + 1;
|
||||
const minChildSize = Math.floor(parentSize / input.childCount);
|
||||
if (minChildSize < 1) {
|
||||
throw new Error(
|
||||
`Cannot suballocate ${String(input.childCount)} child port blocks from parent range ${String(parentStart)}-${String(parentEnd)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const childStart = parentStart + minChildSize * input.childIndex;
|
||||
const childEnd =
|
||||
input.childIndex === input.childCount - 1 ? parentEnd : childStart + minChildSize - 1;
|
||||
const childBlockSize = childEnd - childStart + 1;
|
||||
const primaryOffset = clamp(parentPrimary - childStart, 0, childBlockSize - 1);
|
||||
|
||||
requests.push({
|
||||
kind: "port-range",
|
||||
options: {
|
||||
basePort: childStart,
|
||||
blockSize: childBlockSize,
|
||||
blockCount: 1,
|
||||
primaryPortOffset: primaryOffset,
|
||||
lockDirectory: dirname(lockPath),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return requests;
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
148
src/agents/runtime.ts
Normal file
148
src/agents/runtime.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { AgentManager, type AgentManagerLimits } from "./manager.js";
|
||||
import {
|
||||
createDefaultResourceProvisioningOrchestrator,
|
||||
type BuiltInProvisioningConfigInput,
|
||||
type ResourceProvisioningOrchestrator,
|
||||
} from "./provisioning.js";
|
||||
|
||||
const DEFAULT_LIMITS: AgentManagerLimits = {
|
||||
maxConcurrentAgents: 4,
|
||||
maxSessionAgents: 2,
|
||||
maxRecursiveDepth: 3,
|
||||
};
|
||||
|
||||
const DEFAULT_PROVISIONING_CONFIG: BuiltInProvisioningConfigInput = {
|
||||
gitWorktree: {
|
||||
rootDirectory: ".ai_ops/worktrees",
|
||||
baseRef: "HEAD",
|
||||
},
|
||||
portRange: {
|
||||
basePort: 36000,
|
||||
blockSize: 32,
|
||||
blockCount: 512,
|
||||
primaryPortOffset: 0,
|
||||
lockDirectory: ".ai_ops/locks/ports",
|
||||
},
|
||||
};
|
||||
|
||||
function readPositiveIntegerEnv(
|
||||
key: "AGENT_MAX_CONCURRENT" | "AGENT_MAX_SESSION" | "AGENT_MAX_RECURSIVE_DEPTH",
|
||||
fallback: number,
|
||||
): number {
|
||||
const rawValue = process.env[key]?.trim();
|
||||
if (!rawValue) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number(rawValue);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
throw new Error(`Environment variable ${key} must be a positive integer.`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readOptionalStringEnv(key: string, fallback: string): string {
|
||||
const rawValue = process.env[key]?.trim();
|
||||
if (!rawValue) {
|
||||
return fallback;
|
||||
}
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
function readIntegerEnv(
|
||||
key: string,
|
||||
fallback: number,
|
||||
bounds: {
|
||||
min: number;
|
||||
},
|
||||
): number {
|
||||
const rawValue = process.env[key]?.trim();
|
||||
if (!rawValue) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number(rawValue);
|
||||
if (!Number.isInteger(parsed) || parsed < bounds.min) {
|
||||
throw new Error(`Environment variable ${key} must be an integer >= ${String(bounds.min)}.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function loadAgentManagerLimitsFromEnv(): AgentManagerLimits {
|
||||
return {
|
||||
maxConcurrentAgents: readPositiveIntegerEnv(
|
||||
"AGENT_MAX_CONCURRENT",
|
||||
DEFAULT_LIMITS.maxConcurrentAgents,
|
||||
),
|
||||
maxSessionAgents: readPositiveIntegerEnv(
|
||||
"AGENT_MAX_SESSION",
|
||||
DEFAULT_LIMITS.maxSessionAgents,
|
||||
),
|
||||
maxRecursiveDepth: readPositiveIntegerEnv(
|
||||
"AGENT_MAX_RECURSIVE_DEPTH",
|
||||
DEFAULT_LIMITS.maxRecursiveDepth,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let managerSingleton: AgentManager | undefined;
|
||||
let provisioningSingleton: ResourceProvisioningOrchestrator | undefined;
|
||||
|
||||
export function getAgentManager(): AgentManager {
|
||||
if (!managerSingleton) {
|
||||
managerSingleton = new AgentManager(loadAgentManagerLimitsFromEnv());
|
||||
}
|
||||
|
||||
return managerSingleton;
|
||||
}
|
||||
|
||||
export function loadProvisioningConfigFromEnv(): BuiltInProvisioningConfigInput {
|
||||
return {
|
||||
gitWorktree: {
|
||||
rootDirectory: readOptionalStringEnv(
|
||||
"AGENT_WORKTREE_ROOT",
|
||||
DEFAULT_PROVISIONING_CONFIG.gitWorktree?.rootDirectory ?? ".ai_ops/worktrees",
|
||||
),
|
||||
baseRef: readOptionalStringEnv(
|
||||
"AGENT_WORKTREE_BASE_REF",
|
||||
DEFAULT_PROVISIONING_CONFIG.gitWorktree?.baseRef ?? "HEAD",
|
||||
),
|
||||
},
|
||||
portRange: {
|
||||
basePort: readIntegerEnv(
|
||||
"AGENT_PORT_BASE",
|
||||
DEFAULT_PROVISIONING_CONFIG.portRange?.basePort ?? 36000,
|
||||
{ min: 1 },
|
||||
),
|
||||
blockSize: readIntegerEnv(
|
||||
"AGENT_PORT_BLOCK_SIZE",
|
||||
DEFAULT_PROVISIONING_CONFIG.portRange?.blockSize ?? 32,
|
||||
{ min: 1 },
|
||||
),
|
||||
blockCount: readIntegerEnv(
|
||||
"AGENT_PORT_BLOCK_COUNT",
|
||||
DEFAULT_PROVISIONING_CONFIG.portRange?.blockCount ?? 512,
|
||||
{ min: 1 },
|
||||
),
|
||||
primaryPortOffset: readIntegerEnv(
|
||||
"AGENT_PORT_PRIMARY_OFFSET",
|
||||
DEFAULT_PROVISIONING_CONFIG.portRange?.primaryPortOffset ?? 0,
|
||||
{ min: 0 },
|
||||
),
|
||||
lockDirectory: readOptionalStringEnv(
|
||||
"AGENT_PORT_LOCK_DIR",
|
||||
DEFAULT_PROVISIONING_CONFIG.portRange?.lockDirectory ?? ".ai_ops/locks/ports",
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getResourceProvisioningOrchestrator(): ResourceProvisioningOrchestrator {
|
||||
if (!provisioningSingleton) {
|
||||
provisioningSingleton = createDefaultResourceProvisioningOrchestrator(
|
||||
loadProvisioningConfigFromEnv(),
|
||||
);
|
||||
}
|
||||
return provisioningSingleton;
|
||||
}
|
||||
291
src/agents/state-context.ts
Normal file
291
src/agents/state-context.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { deepCloneJson, isRecord, type JsonObject, type JsonValue } from "./types.js";
|
||||
|
||||
export type SessionHistoryEntry = {
|
||||
nodeId: string;
|
||||
event: string;
|
||||
timestamp: string;
|
||||
data?: JsonObject;
|
||||
};
|
||||
|
||||
export type StoredSessionState = {
|
||||
flags: Record<string, boolean>;
|
||||
metadata: JsonObject;
|
||||
history: SessionHistoryEntry[];
|
||||
};
|
||||
|
||||
export type NodeHandoff = {
|
||||
nodeId: string;
|
||||
fromNodeId?: string;
|
||||
payload: JsonObject;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type NodeExecutionContext = {
|
||||
sessionId: string;
|
||||
nodeId: string;
|
||||
handoff: NodeHandoff;
|
||||
state: StoredSessionState;
|
||||
};
|
||||
|
||||
const DEFAULT_STATE: StoredSessionState = {
|
||||
flags: {},
|
||||
metadata: {},
|
||||
history: [],
|
||||
};
|
||||
|
||||
function toSessionDirectory(rootDirectory: string, sessionId: string): string {
|
||||
return resolve(rootDirectory, sessionId);
|
||||
}
|
||||
|
||||
function toStatePath(rootDirectory: string, sessionId: string): string {
|
||||
return resolve(toSessionDirectory(rootDirectory, sessionId), "state.json");
|
||||
}
|
||||
|
||||
function toHandoffPath(rootDirectory: string, sessionId: string, nodeId: string): string {
|
||||
return resolve(toSessionDirectory(rootDirectory, sessionId), "handoffs", `${nodeId}.json`);
|
||||
}
|
||||
|
||||
function toJsonObject(value: unknown, errorMessage: string): JsonObject {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return value as JsonObject;
|
||||
}
|
||||
|
||||
function toStoredSessionState(value: unknown): StoredSessionState {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Stored state file is malformed.");
|
||||
}
|
||||
|
||||
const flagsValue = value.flags;
|
||||
if (!isRecord(flagsValue)) {
|
||||
throw new Error("Stored state.flags is malformed.");
|
||||
}
|
||||
|
||||
const flags: Record<string, boolean> = {};
|
||||
for (const [key, flagValue] of Object.entries(flagsValue)) {
|
||||
if (typeof flagValue !== "boolean") {
|
||||
throw new Error(`Stored state flag \"${key}\" must be a boolean.`);
|
||||
}
|
||||
flags[key] = flagValue;
|
||||
}
|
||||
|
||||
const metadata = toJsonObject(value.metadata, "Stored state.metadata is malformed.");
|
||||
const historyValue = value.history;
|
||||
if (!Array.isArray(historyValue)) {
|
||||
throw new Error("Stored state.history is malformed.");
|
||||
}
|
||||
|
||||
const history: SessionHistoryEntry[] = [];
|
||||
for (const entry of historyValue) {
|
||||
if (!isRecord(entry)) {
|
||||
throw new Error("Stored state.history entry is malformed.");
|
||||
}
|
||||
|
||||
const nodeId = entry.nodeId;
|
||||
const event = entry.event;
|
||||
const timestamp = entry.timestamp;
|
||||
|
||||
if (typeof nodeId !== "string" || nodeId.trim().length === 0) {
|
||||
throw new Error("Stored state.history entry nodeId is malformed.");
|
||||
}
|
||||
if (typeof event !== "string" || event.trim().length === 0) {
|
||||
throw new Error("Stored state.history entry event is malformed.");
|
||||
}
|
||||
if (typeof timestamp !== "string" || timestamp.trim().length === 0) {
|
||||
throw new Error("Stored state.history entry timestamp is malformed.");
|
||||
}
|
||||
|
||||
const data = entry.data === undefined ? undefined : toJsonObject(entry.data, "Stored state.history entry data is malformed.");
|
||||
|
||||
history.push({
|
||||
nodeId,
|
||||
event,
|
||||
timestamp,
|
||||
...(data ? { data } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
flags,
|
||||
metadata,
|
||||
history,
|
||||
};
|
||||
}
|
||||
|
||||
function toNodeHandoff(value: unknown): NodeHandoff {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Stored handoff file is malformed.");
|
||||
}
|
||||
|
||||
const nodeId = value.nodeId;
|
||||
const createdAt = value.createdAt;
|
||||
const payload = value.payload;
|
||||
|
||||
if (typeof nodeId !== "string" || nodeId.trim().length === 0) {
|
||||
throw new Error("Stored handoff nodeId is malformed.");
|
||||
}
|
||||
if (typeof createdAt !== "string" || createdAt.trim().length === 0) {
|
||||
throw new Error("Stored handoff createdAt is malformed.");
|
||||
}
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error("Stored handoff payload is malformed.");
|
||||
}
|
||||
|
||||
const fromNodeId = value.fromNodeId;
|
||||
if (fromNodeId !== undefined && (typeof fromNodeId !== "string" || fromNodeId.trim().length === 0)) {
|
||||
throw new Error("Stored handoff fromNodeId is malformed.");
|
||||
}
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
...(typeof fromNodeId === "string" ? { fromNodeId } : {}),
|
||||
payload: payload as JsonObject,
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export class FileSystemStateContextManager {
|
||||
private readonly rootDirectory: string;
|
||||
|
||||
constructor(input: {
|
||||
rootDirectory: string;
|
||||
}) {
|
||||
this.rootDirectory = resolve(input.rootDirectory);
|
||||
}
|
||||
|
||||
getRootDirectory(): string {
|
||||
return this.rootDirectory;
|
||||
}
|
||||
|
||||
async initializeSession(
|
||||
sessionId: string,
|
||||
initialState: Partial<StoredSessionState> = {},
|
||||
): Promise<StoredSessionState> {
|
||||
const state: StoredSessionState = {
|
||||
flags: { ...(initialState.flags ?? {}) },
|
||||
metadata: deepCloneJson((initialState.metadata ?? {}) as JsonValue) as JsonObject,
|
||||
history: [...(initialState.history ?? [])],
|
||||
};
|
||||
|
||||
await mkdir(toSessionDirectory(this.rootDirectory, sessionId), { recursive: true });
|
||||
await this.writeState(sessionId, state);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
async readState(sessionId: string): Promise<StoredSessionState> {
|
||||
const path = toStatePath(this.rootDirectory, sessionId);
|
||||
|
||||
try {
|
||||
const content = await readFile(path, "utf8");
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
return toStoredSessionState(parsed);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") {
|
||||
return deepCloneJson(DEFAULT_STATE as JsonValue) as StoredSessionState;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async writeState(sessionId: string, state: StoredSessionState): Promise<void> {
|
||||
const path = toStatePath(this.rootDirectory, sessionId);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async patchState(
|
||||
sessionId: string,
|
||||
patch: {
|
||||
flags?: Record<string, boolean>;
|
||||
metadata?: JsonObject;
|
||||
historyEvent?: SessionHistoryEntry;
|
||||
},
|
||||
): Promise<StoredSessionState> {
|
||||
const current = await this.readState(sessionId);
|
||||
|
||||
if (patch.flags) {
|
||||
Object.assign(current.flags, patch.flags);
|
||||
}
|
||||
if (patch.metadata) {
|
||||
Object.assign(current.metadata, patch.metadata);
|
||||
}
|
||||
if (patch.historyEvent) {
|
||||
current.history.push(patch.historyEvent);
|
||||
}
|
||||
|
||||
await this.writeState(sessionId, current);
|
||||
return current;
|
||||
}
|
||||
|
||||
async writeHandoff(
|
||||
sessionId: string,
|
||||
handoff: {
|
||||
nodeId: string;
|
||||
fromNodeId?: string;
|
||||
payload: JsonObject;
|
||||
},
|
||||
): Promise<NodeHandoff> {
|
||||
const nodeHandoff: NodeHandoff = {
|
||||
nodeId: handoff.nodeId,
|
||||
...(handoff.fromNodeId ? { fromNodeId: handoff.fromNodeId } : {}),
|
||||
payload: deepCloneJson(handoff.payload),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const path = toHandoffPath(this.rootDirectory, sessionId, handoff.nodeId);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, `${JSON.stringify(nodeHandoff, null, 2)}\n`, "utf8");
|
||||
return nodeHandoff;
|
||||
}
|
||||
|
||||
async readHandoff(sessionId: string, nodeId: string): Promise<NodeHandoff | undefined> {
|
||||
const path = toHandoffPath(this.rootDirectory, sessionId, nodeId);
|
||||
|
||||
try {
|
||||
const content = await readFile(path, "utf8");
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
return toNodeHandoff(parsed);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async buildFreshNodeContext(sessionId: string, nodeId: string): Promise<NodeExecutionContext> {
|
||||
const handoff = await this.readHandoff(sessionId, nodeId);
|
||||
if (!handoff) {
|
||||
throw new Error(`No handoff exists for node \"${nodeId}\" in session \"${sessionId}\".`);
|
||||
}
|
||||
|
||||
const state = await this.readState(sessionId);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
nodeId,
|
||||
handoff: {
|
||||
nodeId: handoff.nodeId,
|
||||
...(handoff.fromNodeId ? { fromNodeId: handoff.fromNodeId } : {}),
|
||||
payload: deepCloneJson(handoff.payload),
|
||||
createdAt: handoff.createdAt,
|
||||
},
|
||||
state: {
|
||||
flags: { ...state.flags },
|
||||
metadata: deepCloneJson(state.metadata),
|
||||
history: state.history.map((entry) => ({
|
||||
nodeId: entry.nodeId,
|
||||
event: entry.event,
|
||||
timestamp: entry.timestamp,
|
||||
...(entry.data ? { data: deepCloneJson(entry.data) } : {}),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
14
src/agents/types.ts
Normal file
14
src/agents/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
export type JsonObject = {
|
||||
[key: string]: JsonValue;
|
||||
};
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function deepCloneJson<T extends JsonValue>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
110
src/examples/claude.ts
Normal file
110
src/examples/claude.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import "dotenv/config";
|
||||
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { getAgentManager, getResourceProvisioningOrchestrator } from "../agents/runtime.js";
|
||||
import { loadMcpConfigFromEnv } from "../mcp.js";
|
||||
|
||||
function requiredPrompt(argv: string[]): string {
|
||||
const prompt = argv.slice(2).join(" ").trim();
|
||||
if (!prompt) {
|
||||
throw new Error("Usage: npm run claude -- \"your prompt\"");
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
|
||||
function buildOptions(): Options {
|
||||
return {
|
||||
maxTurns: 1,
|
||||
...(process.env.CLAUDE_MODEL ? { model: process.env.CLAUDE_MODEL } : {}),
|
||||
...(process.env.CLAUDE_CODE_PATH
|
||||
? { pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_PATH }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runClaudePrompt(prompt: string): Promise<void> {
|
||||
const agentManager = getAgentManager();
|
||||
const agentSession = agentManager.createSession();
|
||||
const resourceProvisioning = getResourceProvisioningOrchestrator();
|
||||
const mcp = loadMcpConfigFromEnv({
|
||||
providerHint: "claude",
|
||||
prompt,
|
||||
});
|
||||
let provisionedResources:
|
||||
| Awaited<ReturnType<typeof resourceProvisioning.provisionSession>>
|
||||
| undefined;
|
||||
|
||||
try {
|
||||
provisionedResources = await resourceProvisioning.provisionSession({
|
||||
sessionId: agentSession.id,
|
||||
resources: [{ kind: "git-worktree" }, { kind: "port-range" }],
|
||||
});
|
||||
const runtimeInjection = await provisionedResources.buildRuntimeInjection({
|
||||
discoveryFileRelativePath: process.env.AGENT_DISCOVERY_FILE_RELATIVE_PATH,
|
||||
baseEnv: process.env,
|
||||
});
|
||||
const promptWithContext = provisionedResources.composePrompt(prompt, [
|
||||
`Discovery file: ${runtimeInjection.discoveryFilePath}`,
|
||||
"Resource env vars are pre-injected (AGENT_WORKTREE_PATH, AGENT_PORT_RANGE_START, AGENT_PORT_RANGE_END, AGENT_PORT_PRIMARY).",
|
||||
]);
|
||||
|
||||
const finalResponse = await agentSession.runAgent({
|
||||
depth: 0,
|
||||
run: async () => {
|
||||
const session = query({
|
||||
prompt: promptWithContext,
|
||||
options: {
|
||||
...buildOptions(),
|
||||
...(mcp.claudeMcpServers ? { mcpServers: mcp.claudeMcpServers } : {}),
|
||||
cwd: runtimeInjection.workingDirectory,
|
||||
env: runtimeInjection.env,
|
||||
},
|
||||
});
|
||||
|
||||
let result = "";
|
||||
|
||||
try {
|
||||
for await (const message of session) {
|
||||
if (message.type === "result" && message.subtype === "success") {
|
||||
result = message.result.trim();
|
||||
}
|
||||
|
||||
if (message.type === "result" && message.subtype !== "success") {
|
||||
const detail = message.errors.join("; ");
|
||||
throw new Error(
|
||||
`Claude query failed (${message.subtype})${detail ? `: ${detail}` : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
session.close();
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
throw new Error("Claude run completed without a final result.");
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
console.log(finalResponse);
|
||||
} finally {
|
||||
if (provisionedResources) {
|
||||
await provisionedResources.release();
|
||||
}
|
||||
agentSession.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const prompt = requiredPrompt(process.argv);
|
||||
await runClaudePrompt(prompt);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
78
src/examples/codex.ts
Normal file
78
src/examples/codex.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import "dotenv/config";
|
||||
import { Codex } from "@openai/codex-sdk";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { getAgentManager, getResourceProvisioningOrchestrator } from "../agents/runtime.js";
|
||||
import { loadMcpConfigFromEnv } from "../mcp.js";
|
||||
|
||||
function requiredPrompt(argv: string[]): string {
|
||||
const prompt = argv.slice(2).join(" ").trim();
|
||||
if (!prompt) {
|
||||
throw new Error("Usage: npm run codex -- \"your prompt\"");
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
|
||||
export async function runCodexPrompt(prompt: string): Promise<void> {
|
||||
const agentManager = getAgentManager();
|
||||
const agentSession = agentManager.createSession();
|
||||
const resourceProvisioning = getResourceProvisioningOrchestrator();
|
||||
const apiKey = process.env.CODEX_API_KEY ?? process.env.OPENAI_API_KEY;
|
||||
const mcp = loadMcpConfigFromEnv({
|
||||
providerHint: "codex",
|
||||
prompt,
|
||||
});
|
||||
let provisionedResources:
|
||||
| Awaited<ReturnType<typeof resourceProvisioning.provisionSession>>
|
||||
| undefined;
|
||||
|
||||
try {
|
||||
provisionedResources = await resourceProvisioning.provisionSession({
|
||||
sessionId: agentSession.id,
|
||||
resources: [{ kind: "git-worktree" }, { kind: "port-range" }],
|
||||
});
|
||||
const runtimeInjection = await provisionedResources.buildRuntimeInjection({
|
||||
discoveryFileRelativePath: process.env.AGENT_DISCOVERY_FILE_RELATIVE_PATH,
|
||||
baseEnv: process.env,
|
||||
});
|
||||
|
||||
const codex = new Codex({
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
...(process.env.OPENAI_BASE_URL ? { baseUrl: process.env.OPENAI_BASE_URL } : {}),
|
||||
...(mcp.codexConfig ? { config: mcp.codexConfig } : {}),
|
||||
env: runtimeInjection.env,
|
||||
});
|
||||
|
||||
const thread = codex.startThread({
|
||||
workingDirectory: runtimeInjection.workingDirectory,
|
||||
skipGitRepoCheck: process.env.CODEX_SKIP_GIT_CHECK !== "false",
|
||||
});
|
||||
|
||||
const promptWithContext = provisionedResources.composePrompt(prompt, [
|
||||
`Discovery file: ${runtimeInjection.discoveryFilePath}`,
|
||||
"Resource env vars are pre-injected (AGENT_WORKTREE_PATH, AGENT_PORT_RANGE_START, AGENT_PORT_RANGE_END, AGENT_PORT_PRIMARY).",
|
||||
]);
|
||||
|
||||
const turn = await agentSession.runAgent({
|
||||
depth: 0,
|
||||
run: () => thread.run(promptWithContext),
|
||||
});
|
||||
console.log(turn.finalResponse.trim() || "(No response text returned)");
|
||||
} finally {
|
||||
if (provisionedResources) {
|
||||
await provisionedResources.release();
|
||||
}
|
||||
agentSession.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const prompt = requiredPrompt(process.argv);
|
||||
await runCodexPrompt(prompt);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
41
src/index.ts
Normal file
41
src/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import "dotenv/config";
|
||||
import { runCodexPrompt } from "./examples/codex.js";
|
||||
import { runClaudePrompt } from "./examples/claude.js";
|
||||
|
||||
function usage(): string {
|
||||
return [
|
||||
"Usage:",
|
||||
" npm run dev -- codex \"your prompt\"",
|
||||
" npm run dev -- claude \"your prompt\"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): { provider: "codex" | "claude"; prompt: string } {
|
||||
const providerArg = argv[2]?.toLowerCase();
|
||||
const prompt = argv.slice(3).join(" ").trim();
|
||||
|
||||
if ((providerArg !== "codex" && providerArg !== "claude") || !prompt) {
|
||||
throw new Error(usage());
|
||||
}
|
||||
|
||||
return {
|
||||
provider: providerArg,
|
||||
prompt,
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { provider, prompt } = parseArgs(process.argv);
|
||||
|
||||
if (provider === "codex") {
|
||||
await runCodexPrompt(prompt);
|
||||
return;
|
||||
}
|
||||
|
||||
await runClaudePrompt(prompt);
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
116
src/mcp.ts
Normal file
116
src/mcp.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { CodexOptions } from "@openai/codex-sdk";
|
||||
import {
|
||||
createMcpHandlerShell,
|
||||
listMcpHandlers,
|
||||
registerMcpHandler,
|
||||
resolveServerWithHandler,
|
||||
type McpHandlerBusinessLogic,
|
||||
type McpHandlerBusinessLogicInput,
|
||||
type McpHandlerInput,
|
||||
type McpHandlerResult,
|
||||
type McpHandlerShellOptions,
|
||||
type McpHandlerUtils,
|
||||
type McpServerHandler,
|
||||
} from "./mcp/handlers.js";
|
||||
import type {
|
||||
LoadedMcpConfig,
|
||||
McpLoadContext,
|
||||
SharedMcpConfigFile,
|
||||
} from "./mcp/types.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readConfigFile(pathFromEnv: string | undefined): {
|
||||
config?: SharedMcpConfigFile;
|
||||
sourcePath?: string;
|
||||
} {
|
||||
const explicitPath = pathFromEnv?.trim();
|
||||
const candidatePath = explicitPath || "./mcp.config.json";
|
||||
const resolvedPath = resolve(process.cwd(), candidatePath);
|
||||
|
||||
if (!existsSync(resolvedPath)) {
|
||||
if (explicitPath) {
|
||||
throw new Error(`MCP config file not found: ${resolvedPath}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const rawText = readFileSync(resolvedPath, "utf8");
|
||||
const parsed = JSON.parse(rawText) as unknown;
|
||||
if (!isRecord(parsed)) {
|
||||
throw new Error(`MCP config file must contain a JSON object: ${resolvedPath}`);
|
||||
}
|
||||
|
||||
return { config: parsed as SharedMcpConfigFile, sourcePath: resolvedPath };
|
||||
}
|
||||
|
||||
export function loadMcpConfigFromEnv(context: McpLoadContext = {}): LoadedMcpConfig {
|
||||
const { config, sourcePath } = readConfigFile(process.env.MCP_CONFIG_PATH);
|
||||
if (!config) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const codexServers: NonNullable<CodexOptions["config"]> = {};
|
||||
const claudeServers: NonNullable<LoadedMcpConfig["claudeMcpServers"]> = {};
|
||||
const resolvedHandlers: Record<string, string> = {};
|
||||
|
||||
for (const [serverName, server] of Object.entries(config.servers ?? {})) {
|
||||
const resolved = resolveServerWithHandler({
|
||||
serverName,
|
||||
server,
|
||||
context,
|
||||
fullConfig: config,
|
||||
});
|
||||
resolvedHandlers[serverName] = resolved.handlerId;
|
||||
|
||||
if (resolved.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
if (resolved.codex) {
|
||||
codexServers[serverName] = resolved.codex;
|
||||
}
|
||||
if (resolved.claude) {
|
||||
claudeServers[serverName] = resolved.claude;
|
||||
}
|
||||
}
|
||||
|
||||
const codexWithOverrides = {
|
||||
...codexServers,
|
||||
...(config.codex?.mcp_servers ?? {}),
|
||||
};
|
||||
const claudeWithOverrides = {
|
||||
...claudeServers,
|
||||
...(config.claude?.mcpServers ?? {}),
|
||||
};
|
||||
|
||||
const codexConfig =
|
||||
Object.keys(codexWithOverrides).length > 0
|
||||
? ({ mcp_servers: codexWithOverrides } as NonNullable<CodexOptions["config"]>)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...(codexConfig ? { codexConfig } : {}),
|
||||
...(Object.keys(claudeWithOverrides).length > 0
|
||||
? { claudeMcpServers: claudeWithOverrides }
|
||||
: {}),
|
||||
...(sourcePath ? { sourcePath } : {}),
|
||||
...(Object.keys(resolvedHandlers).length > 0 ? { resolvedHandlers } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export { createMcpHandlerShell, listMcpHandlers, registerMcpHandler };
|
||||
export type {
|
||||
LoadedMcpConfig,
|
||||
McpHandlerBusinessLogic,
|
||||
McpHandlerBusinessLogicInput,
|
||||
McpHandlerInput,
|
||||
McpHandlerResult,
|
||||
McpHandlerShellOptions,
|
||||
McpHandlerUtils,
|
||||
McpLoadContext,
|
||||
McpServerHandler,
|
||||
};
|
||||
76
src/mcp/converters.ts
Normal file
76
src/mcp/converters.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { CodexConfigObject, SharedMcpServer, Transport } from "./types.js";
|
||||
|
||||
export function inferTransport(server: SharedMcpServer): Transport {
|
||||
if (server.type) {
|
||||
return server.type;
|
||||
}
|
||||
return server.url ? "http" : "stdio";
|
||||
}
|
||||
|
||||
export function toCodexServerConfig(serverName: string, server: SharedMcpServer): CodexConfigObject {
|
||||
const type = inferTransport(server);
|
||||
|
||||
if (type === "stdio" && !server.command) {
|
||||
throw new Error(`Shared MCP server "${serverName}" requires "command" for stdio transport.`);
|
||||
}
|
||||
if ((type === "http" || type === "sse") && !server.url) {
|
||||
throw new Error(`Shared MCP server "${serverName}" requires "url" for ${type} transport.`);
|
||||
}
|
||||
|
||||
const config: CodexConfigObject = {};
|
||||
|
||||
if (server.command) config.command = server.command;
|
||||
if (server.args) config.args = server.args;
|
||||
if (server.env) config.env = server.env;
|
||||
if (server.cwd) config.cwd = server.cwd;
|
||||
if (server.url) config.url = server.url;
|
||||
if (server.enabled !== undefined) config.enabled = server.enabled;
|
||||
if (server.required !== undefined) config.required = server.required;
|
||||
if (server.enabled_tools) config.enabled_tools = server.enabled_tools;
|
||||
if (server.disabled_tools) config.disabled_tools = server.disabled_tools;
|
||||
if (server.startup_timeout_sec !== undefined) {
|
||||
config.startup_timeout_sec = server.startup_timeout_sec;
|
||||
}
|
||||
if (server.tool_timeout_sec !== undefined) {
|
||||
config.tool_timeout_sec = server.tool_timeout_sec;
|
||||
}
|
||||
if (server.bearer_token_env_var) {
|
||||
config.bearer_token_env_var = server.bearer_token_env_var;
|
||||
}
|
||||
const httpHeaders = server.http_headers ?? server.headers;
|
||||
if (httpHeaders) {
|
||||
config.http_headers = httpHeaders;
|
||||
}
|
||||
if (server.env_http_headers) config.env_http_headers = server.env_http_headers;
|
||||
if (server.env_vars) config.env_vars = server.env_vars;
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export function toClaudeServerConfig(serverName: string, server: SharedMcpServer): McpServerConfig {
|
||||
const type = inferTransport(server);
|
||||
|
||||
if (type === "stdio") {
|
||||
if (!server.command) {
|
||||
throw new Error(`Shared MCP server "${serverName}" requires "command" for stdio transport.`);
|
||||
}
|
||||
return {
|
||||
type: "stdio",
|
||||
command: server.command,
|
||||
...(server.args ? { args: server.args } : {}),
|
||||
...(server.env ? { env: server.env } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (!server.url) {
|
||||
throw new Error(`Shared MCP server "${serverName}" requires "url" for ${type} transport.`);
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
url: server.url,
|
||||
...(server.headers ? { headers: server.headers } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
233
src/mcp/handlers.ts
Normal file
233
src/mcp/handlers.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
inferTransport,
|
||||
toClaudeServerConfig,
|
||||
toCodexServerConfig,
|
||||
} from "./converters.js";
|
||||
import type {
|
||||
CodexConfigObject,
|
||||
McpLoadContext,
|
||||
SharedMcpConfigFile,
|
||||
SharedMcpServer,
|
||||
} from "./types.js";
|
||||
|
||||
export type McpHandlerUtils = {
|
||||
inferTransport: typeof inferTransport;
|
||||
toCodexServerConfig: typeof toCodexServerConfig;
|
||||
toClaudeServerConfig: typeof toClaudeServerConfig;
|
||||
};
|
||||
|
||||
export type McpHandlerInput = {
|
||||
serverName: string;
|
||||
server: SharedMcpServer;
|
||||
context: McpLoadContext;
|
||||
handlerConfig: Record<string, unknown>;
|
||||
fullConfig: SharedMcpConfigFile;
|
||||
utils: McpHandlerUtils;
|
||||
};
|
||||
|
||||
export type McpHandlerResult = {
|
||||
enabled?: boolean;
|
||||
codex?: CodexConfigObject;
|
||||
claude?: McpServerConfig;
|
||||
};
|
||||
|
||||
export type McpServerHandler = {
|
||||
id: string;
|
||||
description: string;
|
||||
matches: (input: Pick<McpHandlerInput, "serverName" | "server">) => boolean;
|
||||
resolve: (input: McpHandlerInput) => McpHandlerResult;
|
||||
};
|
||||
|
||||
export type McpHandlerBusinessLogicInput = McpHandlerInput & {
|
||||
baseResult: McpHandlerResult;
|
||||
};
|
||||
|
||||
export type McpHandlerBusinessLogic = (
|
||||
input: McpHandlerBusinessLogicInput,
|
||||
) => McpHandlerResult | void;
|
||||
|
||||
export type McpHandlerShellOptions = {
|
||||
id: string;
|
||||
description: string;
|
||||
matches: McpServerHandler["matches"];
|
||||
applyBusinessLogic?: McpHandlerBusinessLogic;
|
||||
};
|
||||
|
||||
const utils: McpHandlerUtils = {
|
||||
inferTransport,
|
||||
toCodexServerConfig,
|
||||
toClaudeServerConfig,
|
||||
};
|
||||
|
||||
function createDefaultResult({
|
||||
serverName,
|
||||
server,
|
||||
localUtils,
|
||||
}: {
|
||||
serverName: string;
|
||||
server: SharedMcpServer;
|
||||
localUtils: McpHandlerUtils;
|
||||
}): McpHandlerResult {
|
||||
return {
|
||||
codex: localUtils.toCodexServerConfig(serverName, server),
|
||||
claude: localUtils.toClaudeServerConfig(serverName, server),
|
||||
};
|
||||
}
|
||||
|
||||
export function createMcpHandlerShell(options: McpHandlerShellOptions): McpServerHandler {
|
||||
const { id, description, matches, applyBusinessLogic } = options;
|
||||
return {
|
||||
id,
|
||||
description,
|
||||
matches,
|
||||
resolve: (input) => {
|
||||
const baseResult = createDefaultResult({
|
||||
serverName: input.serverName,
|
||||
server: input.server,
|
||||
localUtils: input.utils,
|
||||
});
|
||||
const overridden = applyBusinessLogic?.({
|
||||
...input,
|
||||
baseResult,
|
||||
});
|
||||
return overridden ?? baseResult;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isNamedLike(
|
||||
input: Pick<McpHandlerInput, "serverName" | "server">,
|
||||
patterns: string[],
|
||||
): boolean {
|
||||
const values = [input.serverName, input.server.command, input.server.url]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => value.toLowerCase());
|
||||
return patterns.some((pattern) => values.some((value) => value.includes(pattern)));
|
||||
}
|
||||
|
||||
function readBooleanConfigValue(
|
||||
config: Record<string, unknown>,
|
||||
key: string,
|
||||
): boolean | undefined {
|
||||
const value = config[key];
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
function applyEnabledByDefault(input: McpHandlerBusinessLogicInput): McpHandlerResult {
|
||||
if (input.server.enabled !== undefined) {
|
||||
return input.baseResult;
|
||||
}
|
||||
const enabledByDefault = readBooleanConfigValue(input.handlerConfig, "enabledByDefault");
|
||||
return enabledByDefault === false
|
||||
? {
|
||||
...input.baseResult,
|
||||
enabled: false,
|
||||
}
|
||||
: input.baseResult;
|
||||
}
|
||||
|
||||
const context7Handler = createMcpHandlerShell({
|
||||
id: "context7",
|
||||
description:
|
||||
"Dedicated extension point for Context7 policy/behavior. Business logic belongs in applyBusinessLogic.",
|
||||
matches: (input) => isNamedLike(input, ["context7"]),
|
||||
applyBusinessLogic: applyEnabledByDefault,
|
||||
});
|
||||
|
||||
const claudeTaskMasterHandler = createMcpHandlerShell({
|
||||
id: "claude-task-master",
|
||||
description:
|
||||
"Dedicated extension point for Claude Task Master policy/behavior. Business logic belongs in applyBusinessLogic.",
|
||||
matches: (input) =>
|
||||
isNamedLike(input, ["claude-task-master", "task-master", "taskmaster"]),
|
||||
applyBusinessLogic: applyEnabledByDefault,
|
||||
});
|
||||
|
||||
const genericHandler: McpServerHandler = {
|
||||
id: "generic",
|
||||
description: "Default passthrough mapping for project-specific MCP servers.",
|
||||
matches: () => true,
|
||||
resolve: ({ serverName, server, utils: localUtils }) =>
|
||||
createDefaultResult({ serverName, server, localUtils }),
|
||||
};
|
||||
|
||||
const handlerRegistry = new Map<string, McpServerHandler>();
|
||||
const handlerOrder: string[] = [];
|
||||
|
||||
function installBuiltinHandlers(): void {
|
||||
registerMcpHandler(context7Handler);
|
||||
registerMcpHandler(claudeTaskMasterHandler);
|
||||
registerMcpHandler(genericHandler);
|
||||
}
|
||||
|
||||
export function registerMcpHandler(handler: McpServerHandler): void {
|
||||
if (handlerRegistry.has(handler.id)) {
|
||||
handlerRegistry.set(handler.id, handler);
|
||||
return;
|
||||
}
|
||||
handlerRegistry.set(handler.id, handler);
|
||||
handlerOrder.push(handler.id);
|
||||
}
|
||||
|
||||
export function listMcpHandlers(): McpServerHandler[] {
|
||||
return handlerOrder
|
||||
.map((id) => handlerRegistry.get(id))
|
||||
.filter((handler): handler is McpServerHandler => Boolean(handler));
|
||||
}
|
||||
|
||||
function resolveHandler(serverName: string, server: SharedMcpServer): McpServerHandler {
|
||||
if (server.handler) {
|
||||
const explicit = handlerRegistry.get(server.handler);
|
||||
if (!explicit) {
|
||||
throw new Error(
|
||||
`Unknown MCP handler "${server.handler}" configured for server "${serverName}".`,
|
||||
);
|
||||
}
|
||||
return explicit;
|
||||
}
|
||||
|
||||
for (const id of handlerOrder) {
|
||||
const handler = handlerRegistry.get(id);
|
||||
if (!handler || id === "generic") {
|
||||
continue;
|
||||
}
|
||||
if (handler.matches({ serverName, server })) {
|
||||
return handler;
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = handlerRegistry.get("generic");
|
||||
if (!fallback) {
|
||||
throw new Error('No MCP fallback handler registered. Expected handler id "generic".');
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function resolveServerWithHandler(input: {
|
||||
serverName: string;
|
||||
server: SharedMcpServer;
|
||||
context: McpLoadContext;
|
||||
fullConfig: SharedMcpConfigFile;
|
||||
}): McpHandlerResult & { handlerId: string } {
|
||||
const { serverName, server, context, fullConfig } = input;
|
||||
const handler = resolveHandler(serverName, server);
|
||||
const handlerConfig = {
|
||||
...(fullConfig.handlerSettings?.[handler.id] ?? {}),
|
||||
...(server.handlerOptions ?? {}),
|
||||
};
|
||||
const result = handler.resolve({
|
||||
serverName,
|
||||
server,
|
||||
context,
|
||||
handlerConfig,
|
||||
fullConfig,
|
||||
utils,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
handlerId: handler.id,
|
||||
};
|
||||
}
|
||||
|
||||
installBuiltinHandlers();
|
||||
55
src/mcp/types.ts
Normal file
55
src/mcp/types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { CodexOptions } from "@openai/codex-sdk";
|
||||
|
||||
export type Transport = "stdio" | "http" | "sse";
|
||||
|
||||
export type CodexConfigValue = string | number | boolean | CodexConfigValue[] | CodexConfigObject;
|
||||
export type CodexConfigObject = {
|
||||
[key: string]: CodexConfigValue;
|
||||
};
|
||||
|
||||
export type SharedMcpServer = {
|
||||
type?: Transport;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
required?: boolean;
|
||||
enabled_tools?: string[];
|
||||
disabled_tools?: string[];
|
||||
startup_timeout_sec?: number;
|
||||
tool_timeout_sec?: number;
|
||||
bearer_token_env_var?: string;
|
||||
http_headers?: Record<string, string>;
|
||||
env_http_headers?: Record<string, string>;
|
||||
env_vars?: string[];
|
||||
handler?: string;
|
||||
handlerOptions?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type SharedMcpConfigFile = {
|
||||
servers?: Record<string, SharedMcpServer>;
|
||||
codex?: {
|
||||
mcp_servers?: Record<string, CodexConfigObject>;
|
||||
};
|
||||
claude?: {
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
};
|
||||
handlerSettings?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type McpLoadContext = {
|
||||
providerHint?: "codex" | "claude" | "both";
|
||||
prompt?: string;
|
||||
};
|
||||
|
||||
export type LoadedMcpConfig = {
|
||||
codexConfig?: NonNullable<CodexOptions["config"]>;
|
||||
claudeMcpServers?: Record<string, McpServerConfig>;
|
||||
sourcePath?: string;
|
||||
resolvedHandlers?: Record<string, string>;
|
||||
};
|
||||
|
||||
460
tests/agent-manager.test.ts
Normal file
460
tests/agent-manager.test.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { AgentManager } from "../src/agents/manager.js";
|
||||
import {
|
||||
ResourceProvisioningOrchestrator,
|
||||
type DiscoverySnapshot,
|
||||
type ResourceProvider,
|
||||
} from "../src/agents/provisioning.js";
|
||||
|
||||
test("queues work when session concurrency is saturated", async () => {
|
||||
const manager = new AgentManager({
|
||||
maxConcurrentAgents: 1,
|
||||
maxSessionAgents: 1,
|
||||
maxRecursiveDepth: 2,
|
||||
});
|
||||
const session = manager.createSession("session-a");
|
||||
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
const firstStarted = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
let secondStarted = false;
|
||||
|
||||
const firstRun = session.runAgent({
|
||||
depth: 0,
|
||||
run: async () => {
|
||||
await firstStarted;
|
||||
return "first";
|
||||
},
|
||||
});
|
||||
|
||||
const secondRun = session.runAgent({
|
||||
depth: 0,
|
||||
run: async () => {
|
||||
secondStarted = true;
|
||||
return "second";
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(secondStarted, false);
|
||||
|
||||
assert.ok(releaseFirst);
|
||||
releaseFirst();
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([firstRun, secondRun]);
|
||||
assert.equal(firstResult, "first");
|
||||
assert.equal(secondResult, "second");
|
||||
assert.equal(manager.getActiveAgentCount(), 0);
|
||||
});
|
||||
|
||||
test("rejects agent runs above recursive depth limit", async () => {
|
||||
const manager = new AgentManager({
|
||||
maxConcurrentAgents: 2,
|
||||
maxSessionAgents: 2,
|
||||
maxRecursiveDepth: 1,
|
||||
});
|
||||
const session = manager.createSession("session-b");
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
session.runAgent({
|
||||
depth: 2,
|
||||
run: async () => "unreachable",
|
||||
}),
|
||||
/exceeds maxRecursiveDepth/,
|
||||
);
|
||||
});
|
||||
|
||||
test("closing a session rejects queued runs", async () => {
|
||||
const manager = new AgentManager({
|
||||
maxConcurrentAgents: 1,
|
||||
maxSessionAgents: 1,
|
||||
maxRecursiveDepth: 1,
|
||||
});
|
||||
const session = manager.createSession("session-c");
|
||||
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
const firstRun = session.runAgent({
|
||||
depth: 0,
|
||||
run: async () =>
|
||||
new Promise<string>((resolve) => {
|
||||
releaseFirst = () => resolve("first");
|
||||
}),
|
||||
});
|
||||
|
||||
const queuedRun = session.runAgent({
|
||||
depth: 0,
|
||||
run: async () => "second",
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
session.close();
|
||||
|
||||
await assert.rejects(() => queuedRun, /was closed/);
|
||||
assert.ok(releaseFirst);
|
||||
releaseFirst();
|
||||
await firstRun;
|
||||
});
|
||||
|
||||
test("recursive fanout/fan-in avoids deadlock at maxConcurrentAgents=1", async () => {
|
||||
const manager = new AgentManager({
|
||||
maxConcurrentAgents: 1,
|
||||
maxSessionAgents: 1,
|
||||
maxRecursiveDepth: 3,
|
||||
});
|
||||
const session = manager.createSession("recursive-deadlock");
|
||||
|
||||
const executionOrder: string[] = [];
|
||||
const result = await manager.runRecursiveAgent({
|
||||
sessionId: session.id,
|
||||
depth: 0,
|
||||
run: async ({ sessionId, intent }) => {
|
||||
executionOrder.push(`${sessionId}:${intent?.task ?? "root"}`);
|
||||
if (!intent) {
|
||||
return {
|
||||
type: "fanout" as const,
|
||||
intents: [
|
||||
{
|
||||
persona: "coder",
|
||||
task: "build-child",
|
||||
},
|
||||
],
|
||||
aggregate: ({ childResults }) => childResults[0]?.output ?? "missing",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "complete" as const,
|
||||
output: `done:${sessionId}`,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, "done:recursive-deadlock_child_1");
|
||||
assert.deepEqual(executionOrder, [
|
||||
"recursive-deadlock:root",
|
||||
"recursive-deadlock_child_1:build-child",
|
||||
]);
|
||||
assert.equal(manager.getActiveAgentCount(), 0);
|
||||
session.close();
|
||||
});
|
||||
|
||||
test("rejects recursive child spawn above depth limit", async () => {
|
||||
const manager = new AgentManager({
|
||||
maxConcurrentAgents: 2,
|
||||
maxSessionAgents: 2,
|
||||
maxRecursiveDepth: 2,
|
||||
});
|
||||
const session = manager.createSession("recursive-depth");
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
manager.runRecursiveAgent({
|
||||
sessionId: session.id,
|
||||
depth: 0,
|
||||
run: async ({ depth }) => {
|
||||
if (depth < 3) {
|
||||
return {
|
||||
type: "fanout" as const,
|
||||
intents: [
|
||||
{
|
||||
persona: "coder",
|
||||
task: `spawn-${String(depth + 1)}`,
|
||||
},
|
||||
],
|
||||
aggregate: ({ childResults }) => childResults[0]?.output ?? "missing",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "complete" as const,
|
||||
output: `leaf-${String(depth)}`,
|
||||
};
|
||||
},
|
||||
}),
|
||||
/Cannot spawn child at depth 3/,
|
||||
);
|
||||
session.close();
|
||||
});
|
||||
|
||||
test("closing parent session aborts active recursive work and releases child resources", async () => {
|
||||
const manager = new AgentManager({
|
||||
maxConcurrentAgents: 2,
|
||||
maxSessionAgents: 2,
|
||||
maxRecursiveDepth: 3,
|
||||
});
|
||||
const session = manager.createSession("recursive-abort");
|
||||
|
||||
let notifyChildStarted: (() => void) | undefined;
|
||||
const childStarted = new Promise<void>((resolve) => {
|
||||
notifyChildStarted = resolve;
|
||||
});
|
||||
|
||||
let abortCount = 0;
|
||||
let releaseCount = 0;
|
||||
|
||||
const runPromise = manager.runRecursiveAgent({
|
||||
sessionId: session.id,
|
||||
depth: 0,
|
||||
run: async ({ intent, signal }) => {
|
||||
if (!intent) {
|
||||
return {
|
||||
type: "fanout" as const,
|
||||
intents: [
|
||||
{
|
||||
persona: "coder",
|
||||
task: "long-running",
|
||||
},
|
||||
],
|
||||
aggregate: () => "unreachable",
|
||||
};
|
||||
}
|
||||
|
||||
notifyChildStarted?.();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, 5000);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
abortCount += 1;
|
||||
reject(signal.reason ?? new Error("Aborted"));
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
|
||||
return {
|
||||
type: "complete" as const,
|
||||
output: "child-done",
|
||||
};
|
||||
},
|
||||
childMiddleware: {
|
||||
releaseForChild: async () => {
|
||||
releaseCount += 1;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await childStarted;
|
||||
session.close();
|
||||
|
||||
await assert.rejects(() => runPromise, /(AbortError|aborted|closed)/i);
|
||||
assert.equal(abortCount, 1);
|
||||
assert.equal(releaseCount, 1);
|
||||
assert.equal(manager.getActiveAgentCount(), 0);
|
||||
});
|
||||
|
||||
test("recursive children can be isolated via middleware-backed suballocation", async () => {
|
||||
const manager = new AgentManager({
|
||||
maxConcurrentAgents: 2,
|
||||
maxSessionAgents: 2,
|
||||
maxRecursiveDepth: 2,
|
||||
});
|
||||
const session = manager.createSession("recursive-isolation");
|
||||
const provisioner = new ResourceProvisioningOrchestrator([
|
||||
createTestGitWorktreeProvider(),
|
||||
createTestPortRangeProvider(),
|
||||
]);
|
||||
|
||||
const parentResources = await provisioner.provisionSession({
|
||||
sessionId: session.id,
|
||||
workspaceRoot: "/repo",
|
||||
resources: [
|
||||
{
|
||||
kind: "git-worktree",
|
||||
options: {
|
||||
rootDirectory: "/repo/.ai_ops/worktrees",
|
||||
baseRef: "HEAD",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "port-range",
|
||||
options: {
|
||||
basePort: 41000,
|
||||
blockSize: 20,
|
||||
blockCount: 1,
|
||||
primaryPortOffset: 0,
|
||||
lockDirectory: "/repo/.ai_ops/locks/ports",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const parentSnapshot = parentResources.toDiscoverySnapshot();
|
||||
const childSnapshots = new Map<string, DiscoverySnapshot>();
|
||||
const childLeases = new Map<string, Awaited<ReturnType<typeof provisioner.provisionChildSession>>>();
|
||||
|
||||
try {
|
||||
await manager.runRecursiveAgent({
|
||||
sessionId: session.id,
|
||||
depth: 0,
|
||||
run: async ({ intent, sessionId }) => {
|
||||
if (!intent) {
|
||||
return {
|
||||
type: "fanout" as const,
|
||||
intents: [
|
||||
{ persona: "coder", task: "child-a" },
|
||||
{ persona: "coder", task: "child-b" },
|
||||
],
|
||||
aggregate: ({ childResults }) => childResults.map((entry) => entry.output).join(","),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "complete" as const,
|
||||
output: sessionId,
|
||||
};
|
||||
},
|
||||
childMiddleware: {
|
||||
allocateForChild: async ({ childSessionId, childIndex, childCount }) => {
|
||||
const lease = await provisioner.provisionChildSession({
|
||||
parentSnapshot,
|
||||
childSessionId,
|
||||
childIndex,
|
||||
childCount,
|
||||
});
|
||||
childLeases.set(childSessionId, lease);
|
||||
childSnapshots.set(childSessionId, lease.toDiscoverySnapshot());
|
||||
},
|
||||
releaseForChild: async ({ childSessionId }) => {
|
||||
const lease = childLeases.get(childSessionId);
|
||||
if (!lease) {
|
||||
return;
|
||||
}
|
||||
|
||||
await lease.release();
|
||||
childLeases.delete(childSessionId);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const childOneSnapshot = childSnapshots.get("recursive-isolation_child_1");
|
||||
const childTwoSnapshot = childSnapshots.get("recursive-isolation_child_2");
|
||||
assert.ok(childOneSnapshot);
|
||||
assert.ok(childTwoSnapshot);
|
||||
|
||||
const childOnePort = readPortRangeConstraint(childOneSnapshot);
|
||||
const childTwoPort = readPortRangeConstraint(childTwoSnapshot);
|
||||
assert.ok(childOnePort.endPort < childTwoPort.startPort);
|
||||
|
||||
const childOneWorktree = readGitConstraint(childOneSnapshot).worktreePath;
|
||||
const childTwoWorktree = readGitConstraint(childTwoSnapshot).worktreePath;
|
||||
assert.notEqual(childOneWorktree, childTwoWorktree);
|
||||
} finally {
|
||||
for (const lease of childLeases.values()) {
|
||||
await lease.release();
|
||||
}
|
||||
await parentResources.release();
|
||||
session.close();
|
||||
}
|
||||
});
|
||||
|
||||
function createTestGitWorktreeProvider(): ResourceProvider {
|
||||
return {
|
||||
kind: "git-worktree",
|
||||
provision: async ({ sessionId, workspaceRoot, options }) => {
|
||||
const rootDirectory =
|
||||
typeof options.rootDirectory === "string" ? options.rootDirectory : `${workspaceRoot}/.ai_ops/worktrees`;
|
||||
const baseRef = typeof options.baseRef === "string" ? options.baseRef : "HEAD";
|
||||
const worktreePath = `${rootDirectory}/${sessionId}`;
|
||||
|
||||
return {
|
||||
kind: "git-worktree",
|
||||
hard: {
|
||||
repoRoot: workspaceRoot,
|
||||
worktreeRoot: rootDirectory,
|
||||
worktreePath,
|
||||
baseRef,
|
||||
},
|
||||
soft: {
|
||||
preferredWorkingDirectory: worktreePath,
|
||||
},
|
||||
release: async () => {},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTestPortRangeProvider(): ResourceProvider {
|
||||
return {
|
||||
kind: "port-range",
|
||||
provision: async ({ sessionId, options }) => {
|
||||
const basePort = readNumberOption(options, "basePort", 36000);
|
||||
const blockSize = readNumberOption(options, "blockSize", 32);
|
||||
const blockCount = readNumberOption(options, "blockCount", 1);
|
||||
const primaryOffset = readNumberOption(options, "primaryPortOffset", 0);
|
||||
const blockIndex = 0;
|
||||
const startPort = basePort + blockIndex * blockSize;
|
||||
const endPort = startPort + blockSize - 1;
|
||||
const primaryPort = startPort + primaryOffset;
|
||||
const lockDirectory = readStringOption(options, "lockDirectory", "/tmp");
|
||||
|
||||
return {
|
||||
kind: "port-range",
|
||||
hard: {
|
||||
basePort,
|
||||
blockSize,
|
||||
blockCount,
|
||||
blockIndex,
|
||||
startPort,
|
||||
endPort,
|
||||
primaryPort,
|
||||
lockPath: `${lockDirectory}/${startPort}-${endPort}-${sessionId}.lock`,
|
||||
},
|
||||
release: async () => {},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readNumberOption(options: Record<string, unknown>, key: string, fallback: number): number {
|
||||
const value = options[key];
|
||||
if (typeof value === "number" && Number.isInteger(value)) {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function readStringOption(options: Record<string, unknown>, key: string, fallback: string): string {
|
||||
const value = options[key];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function readHardConstraint(snapshot: DiscoverySnapshot, kind: string): Record<string, unknown> {
|
||||
const constraint = snapshot.hardConstraints.find((entry) => entry.kind === kind);
|
||||
assert.ok(constraint);
|
||||
return constraint.allocation as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function readGitConstraint(snapshot: DiscoverySnapshot): {
|
||||
worktreePath: string;
|
||||
} {
|
||||
const allocation = readHardConstraint(snapshot, "git-worktree");
|
||||
const worktreePath = allocation.worktreePath;
|
||||
assert.equal(typeof worktreePath, "string");
|
||||
if (typeof worktreePath !== "string") {
|
||||
throw new Error("Expected git-worktree allocation to include worktreePath.");
|
||||
}
|
||||
return {
|
||||
worktreePath,
|
||||
};
|
||||
}
|
||||
|
||||
function readPortRangeConstraint(snapshot: DiscoverySnapshot): {
|
||||
startPort: number;
|
||||
endPort: number;
|
||||
} {
|
||||
const allocation = readHardConstraint(snapshot, "port-range");
|
||||
const startPort = allocation.startPort;
|
||||
const endPort = allocation.endPort;
|
||||
assert.equal(typeof startPort, "number");
|
||||
assert.equal(typeof endPort, "number");
|
||||
if (typeof startPort !== "number" || typeof endPort !== "number") {
|
||||
throw new Error("Expected port-range allocation to include numeric startPort/endPort.");
|
||||
}
|
||||
return {
|
||||
startPort,
|
||||
endPort,
|
||||
};
|
||||
}
|
||||
119
tests/manifest-schema.test.ts
Normal file
119
tests/manifest-schema.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseAgentManifest } from "../src/agents/manifest.js";
|
||||
|
||||
function validManifest(): unknown {
|
||||
return {
|
||||
schemaVersion: "1",
|
||||
topologies: ["hierarchical", "retry-unrolled", "sequential"],
|
||||
personas: [
|
||||
{
|
||||
id: "product",
|
||||
displayName: "Product",
|
||||
systemPromptTemplate: "Product for {{repo}}",
|
||||
toolClearance: {
|
||||
allowlist: ["read_file"],
|
||||
banlist: ["delete_file"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "coder",
|
||||
displayName: "Coder",
|
||||
systemPromptTemplate: "Coder for {{repo}}",
|
||||
toolClearance: {
|
||||
allowlist: ["read_file", "write_file"],
|
||||
banlist: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
relationships: [
|
||||
{
|
||||
parentPersonaId: "product",
|
||||
childPersonaId: "coder",
|
||||
constraints: {
|
||||
maxDepth: 2,
|
||||
maxChildren: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
topologyConstraints: {
|
||||
maxDepth: 5,
|
||||
maxRetries: 2,
|
||||
},
|
||||
pipeline: {
|
||||
entryNodeId: "product-node",
|
||||
nodes: [
|
||||
{
|
||||
id: "product-node",
|
||||
actorId: "product_actor",
|
||||
personaId: "product",
|
||||
},
|
||||
{
|
||||
id: "coder-node",
|
||||
actorId: "coder_actor",
|
||||
personaId: "coder",
|
||||
constraints: {
|
||||
maxRetries: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
from: "product-node",
|
||||
to: "coder-node",
|
||||
on: "success",
|
||||
when: [{ type: "always" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("parses a valid AgentManifest", () => {
|
||||
const manifest = parseAgentManifest(validManifest());
|
||||
assert.equal(manifest.schemaVersion, "1");
|
||||
assert.equal(manifest.pipeline.nodes.length, 2);
|
||||
assert.equal(manifest.relationships.length, 1);
|
||||
});
|
||||
|
||||
test("rejects pipeline cycles", () => {
|
||||
const manifest = validManifest() as {
|
||||
pipeline: {
|
||||
edges: Array<{ from: string; to: string; on: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
manifest.pipeline.edges.push({
|
||||
from: "coder-node",
|
||||
to: "product-node",
|
||||
on: "success",
|
||||
});
|
||||
|
||||
assert.throws(() => parseAgentManifest(manifest), /strict DAG/);
|
||||
});
|
||||
|
||||
test("rejects relationship with unknown persona", () => {
|
||||
const manifest = validManifest() as {
|
||||
relationships: Array<{ parentPersonaId: string; childPersonaId: string }>;
|
||||
};
|
||||
|
||||
manifest.relationships.push({
|
||||
parentPersonaId: "product",
|
||||
childPersonaId: "unknown",
|
||||
});
|
||||
|
||||
assert.throws(() => parseAgentManifest(manifest), /unknown child persona/);
|
||||
});
|
||||
|
||||
test("rejects relationship cycles", () => {
|
||||
const manifest = validManifest() as {
|
||||
relationships: Array<{ parentPersonaId: string; childPersonaId: string }>;
|
||||
};
|
||||
|
||||
manifest.relationships.push({
|
||||
parentPersonaId: "coder",
|
||||
childPersonaId: "product",
|
||||
});
|
||||
|
||||
assert.throws(() => parseAgentManifest(manifest), /Relationship graph must be acyclic/);
|
||||
});
|
||||
49
tests/mcp-converters.test.ts
Normal file
49
tests/mcp-converters.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
inferTransport,
|
||||
toClaudeServerConfig,
|
||||
toCodexServerConfig,
|
||||
} from "../src/mcp/converters.js";
|
||||
|
||||
test("infers stdio transport when url is absent", () => {
|
||||
const transport = inferTransport({
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-memory"],
|
||||
});
|
||||
assert.equal(transport, "stdio");
|
||||
});
|
||||
|
||||
test("infers http transport when url is present", () => {
|
||||
const transport = inferTransport({
|
||||
url: "http://localhost:3000/mcp",
|
||||
});
|
||||
assert.equal(transport, "http");
|
||||
});
|
||||
|
||||
test("throws for stdio codex server without command", () => {
|
||||
assert.throws(
|
||||
() => toCodexServerConfig("bad-server", { type: "stdio" }),
|
||||
/requires "command" for stdio transport/,
|
||||
);
|
||||
});
|
||||
|
||||
test("maps shared headers to codex http_headers", () => {
|
||||
const codexConfig = toCodexServerConfig("headers-server", {
|
||||
url: "http://localhost:3000/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer token",
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(codexConfig.http_headers, {
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws for claude http server without url", () => {
|
||||
assert.throws(
|
||||
() => toClaudeServerConfig("bad-http", { type: "http" }),
|
||||
/requires "url" for http transport/,
|
||||
);
|
||||
});
|
||||
248
tests/orchestration-engine.test.ts
Normal file
248
tests/orchestration-engine.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { SchemaDrivenExecutionEngine } from "../src/agents/orchestration.js";
|
||||
import type { ActorExecutionResult } from "../src/agents/pipeline.js";
|
||||
|
||||
function createManifest(): unknown {
|
||||
return {
|
||||
schemaVersion: "1",
|
||||
topologies: ["hierarchical", "retry-unrolled", "sequential"],
|
||||
personas: [
|
||||
{
|
||||
id: "product",
|
||||
displayName: "Product",
|
||||
systemPromptTemplate: "Product planning for {{repo}}",
|
||||
toolClearance: {
|
||||
allowlist: ["read_file"],
|
||||
banlist: ["delete_file"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "task",
|
||||
displayName: "Task",
|
||||
systemPromptTemplate: "Task planning for {{repo}}",
|
||||
toolClearance: {
|
||||
allowlist: ["read_file", "write_file"],
|
||||
banlist: ["git_reset"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "coder",
|
||||
displayName: "Coder",
|
||||
systemPromptTemplate: "Coder implements {{ticket}}",
|
||||
toolClearance: {
|
||||
allowlist: ["read_file", "write_file"],
|
||||
banlist: ["rm"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "qa",
|
||||
displayName: "QA",
|
||||
systemPromptTemplate: "QA validates {{ticket}}",
|
||||
toolClearance: {
|
||||
allowlist: ["read_file"],
|
||||
banlist: ["write_file"],
|
||||
},
|
||||
},
|
||||
],
|
||||
relationships: [
|
||||
{
|
||||
parentPersonaId: "product",
|
||||
childPersonaId: "task",
|
||||
constraints: {
|
||||
maxChildren: 2,
|
||||
maxDepth: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
parentPersonaId: "task",
|
||||
childPersonaId: "coder",
|
||||
constraints: {
|
||||
maxChildren: 3,
|
||||
maxDepth: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
topologyConstraints: {
|
||||
maxDepth: 6,
|
||||
maxRetries: 2,
|
||||
},
|
||||
pipeline: {
|
||||
entryNodeId: "project-gate",
|
||||
nodes: [
|
||||
{
|
||||
id: "project-gate",
|
||||
actorId: "project_gate",
|
||||
personaId: "product",
|
||||
},
|
||||
{
|
||||
id: "task-plan",
|
||||
actorId: "task_plan",
|
||||
personaId: "task",
|
||||
},
|
||||
{
|
||||
id: "coder-1",
|
||||
actorId: "coder",
|
||||
personaId: "coder",
|
||||
constraints: {
|
||||
maxRetries: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "qa-1",
|
||||
actorId: "qa",
|
||||
personaId: "qa",
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
from: "project-gate",
|
||||
to: "task-plan",
|
||||
on: "success",
|
||||
when: [
|
||||
{
|
||||
type: "state_flag",
|
||||
key: "needs_bootstrap",
|
||||
equals: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
from: "project-gate",
|
||||
to: "coder-1",
|
||||
on: "success",
|
||||
when: [
|
||||
{
|
||||
type: "state_flag",
|
||||
key: "needs_bootstrap",
|
||||
equals: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
from: "task-plan",
|
||||
to: "coder-1",
|
||||
on: "success",
|
||||
},
|
||||
{
|
||||
from: "coder-1",
|
||||
to: "qa-1",
|
||||
on: "success",
|
||||
when: [
|
||||
{
|
||||
type: "history_has_event",
|
||||
event: "validation_fail",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("runs DAG pipeline with state-dependent routing and retry behavior", async () => {
|
||||
const workspaceRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-workspace-"));
|
||||
const stateRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-session-state-"));
|
||||
|
||||
await writeFile(resolve(workspaceRoot, "PRD.md"), "# PRD\n", "utf8");
|
||||
|
||||
let coderAttempts = 0;
|
||||
|
||||
const engine = new SchemaDrivenExecutionEngine({
|
||||
manifest: createManifest(),
|
||||
settings: {
|
||||
workspaceRoot,
|
||||
stateRoot,
|
||||
runtimeContext: {
|
||||
repo: "ai_ops",
|
||||
ticket: "AIOPS-123",
|
||||
},
|
||||
maxChildren: 4,
|
||||
maxDepth: 8,
|
||||
maxRetries: 3,
|
||||
},
|
||||
actorExecutors: {
|
||||
project_gate: async () => ({
|
||||
status: "success",
|
||||
payload: {
|
||||
phase: "gate",
|
||||
},
|
||||
stateFlags: {
|
||||
needs_bootstrap: true,
|
||||
},
|
||||
}),
|
||||
task_plan: async (input) => {
|
||||
assert.match(input.prompt, /ai_ops/);
|
||||
return {
|
||||
status: "success",
|
||||
payload: {
|
||||
plan: "roadmap",
|
||||
},
|
||||
stateFlags: {
|
||||
roadmap_ready: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
coder: async (input): Promise<ActorExecutionResult> => {
|
||||
assert.match(input.prompt, /AIOPS-123/);
|
||||
assert.deepEqual(input.toolClearance.allowlist, ["read_file", "write_file"]);
|
||||
coderAttempts += 1;
|
||||
if (coderAttempts === 1) {
|
||||
return {
|
||||
status: "validation_fail",
|
||||
payload: {
|
||||
issue: "missing test",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
payload: {
|
||||
code: "done",
|
||||
},
|
||||
};
|
||||
},
|
||||
qa: async () => ({
|
||||
status: "success",
|
||||
payload: {
|
||||
qa: "ok",
|
||||
},
|
||||
}),
|
||||
},
|
||||
behaviorHandlers: {
|
||||
coder: {
|
||||
onValidationFail: () => ({
|
||||
lastValidationFailure: "coder-1",
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await engine.runSession({
|
||||
sessionId: "session-orchestration-1",
|
||||
initialPayload: {
|
||||
task: "Implement pipeline",
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
result.records.map((record) => `${record.nodeId}:${record.status}:${String(record.attempt)}`),
|
||||
[
|
||||
"project-gate:success:1",
|
||||
"task-plan:success:1",
|
||||
"coder-1:validation_fail:1",
|
||||
"coder-1:success:2",
|
||||
"qa-1:success:1",
|
||||
],
|
||||
);
|
||||
|
||||
assert.equal(result.finalState.flags.needs_bootstrap, true);
|
||||
assert.equal(result.finalState.flags.roadmap_ready, true);
|
||||
assert.equal(result.finalState.metadata.lastValidationFailure, "coder-1");
|
||||
|
||||
assert.deepEqual(engine.planChildPersonas({ parentPersonaId: "task", depth: 1 }), ["coder"]);
|
||||
});
|
||||
64
tests/resource-suballocation.test.ts
Normal file
64
tests/resource-suballocation.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildChildResourceRequests,
|
||||
type DiscoverySnapshot,
|
||||
} from "../src/agents/provisioning.js";
|
||||
|
||||
function parentSnapshot(): DiscoverySnapshot {
|
||||
return {
|
||||
sessionId: "parent-session",
|
||||
workspaceRoot: "/repo",
|
||||
workingDirectory: "/repo/.ai_ops/worktrees/parent",
|
||||
hardConstraints: [
|
||||
{
|
||||
kind: "git-worktree",
|
||||
allocation: {
|
||||
repoRoot: "/repo",
|
||||
worktreeRoot: "/repo/.ai_ops/worktrees",
|
||||
worktreePath: "/repo/.ai_ops/worktrees/parent",
|
||||
baseRef: "HEAD",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "port-range",
|
||||
allocation: {
|
||||
basePort: 36000,
|
||||
blockSize: 32,
|
||||
blockCount: 512,
|
||||
blockIndex: 2,
|
||||
startPort: 36064,
|
||||
endPort: 36095,
|
||||
primaryPort: 36064,
|
||||
lockPath: "/repo/.ai_ops/locks/ports/36064-36095.lock",
|
||||
},
|
||||
},
|
||||
],
|
||||
softConstraints: {
|
||||
env: {},
|
||||
promptSections: [],
|
||||
metadata: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("builds deterministic child suballocation requests", () => {
|
||||
const requests = buildChildResourceRequests({
|
||||
parentSnapshot: parentSnapshot(),
|
||||
childSessionId: "child-1",
|
||||
childIndex: 1,
|
||||
childCount: 4,
|
||||
});
|
||||
|
||||
assert.equal(requests.length, 2);
|
||||
|
||||
const gitRequest = requests.find((entry) => entry.kind === "git-worktree");
|
||||
assert.ok(gitRequest);
|
||||
assert.equal(typeof gitRequest.options?.rootDirectory, "string");
|
||||
|
||||
const portRequest = requests.find((entry) => entry.kind === "port-range");
|
||||
assert.ok(portRequest);
|
||||
assert.equal(portRequest.options?.basePort, 36072);
|
||||
assert.equal(portRequest.options?.blockSize, 8);
|
||||
assert.equal(portRequest.options?.blockCount, 1);
|
||||
});
|
||||
28
tests/state-context.test.ts
Normal file
28
tests/state-context.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { FileSystemStateContextManager } from "../src/agents/state-context.js";
|
||||
|
||||
test("state/context manager builds fresh contexts from handoff storage", async () => {
|
||||
const root = await mkdtemp(resolve(tmpdir(), "ai-ops-state-"));
|
||||
const manager = new FileSystemStateContextManager({
|
||||
rootDirectory: root,
|
||||
});
|
||||
|
||||
await manager.initializeSession("session-1");
|
||||
await manager.writeHandoff("session-1", {
|
||||
nodeId: "coder",
|
||||
payload: {
|
||||
task: "build feature",
|
||||
attempt: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const first = await manager.buildFreshNodeContext("session-1", "coder");
|
||||
first.handoff.payload.task = "mutated";
|
||||
|
||||
const second = await manager.buildFreshNodeContext("session-1", "coder");
|
||||
assert.equal(second.handoff.payload.task, "build feature");
|
||||
});
|
||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
8
tsconfig.test.json
Normal file
8
tsconfig.test.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user