RFC-005: Separate Agent and Role — type-level refactor #267

Closed
opened 2026-04-30 06:21:22 +00:00 by xiaomo · 0 comments
Owner

Summary

Refactor the workflow type system to cleanly separate Agent (pure execution capability) from Role (identity + purpose + structured output). This clarifies the layering and simplifies adapter integration.

Motivation

Current Role<Meta> conflates two concerns:

  1. Execution — calling an LLM / script / HTTP endpoint
  2. Identity — knowing who you are, what to report, what schema to follow

The current signature (start: StartStep, messages: WorkflowMessage[]) => Promise<RoleResult<Meta>> leaks engine-level details (maxRounds, dryRun) into every role implementation. Adapter authors (hermes, cursor) have to destructure fields they don't care about.

Design

Core Insight

Role = Agent + Identity
Identity = { prompt, schema }

New Types

// Agent: pure text-in text-out execution capability
type AgentFn = (ctx: ThreadContext) => Promise<string>

// Shared thread context — consumed by both Role and Moderator
type ThreadContext<M extends RoleMeta> = {
  threadId: string
  start: StartStep
  steps: RoleStep<M>[]
}

// Role: identity-bound execution, returns structured output
type Role<Meta> = (ctx: ThreadContext) => Promise<RoleResult<Meta>>

// Moderator: pure routing, same context type
type Moderator<M extends RoleMeta> = (ctx: ThreadContext<M>) => (keyof M & string) | END

Layering

Layer Type Input Output Knows about
Agent AgentFn ThreadContext string Thread context, but no identity/schema
Role Role<M> ThreadContext { content, meta } Identity, schema, thread history
Moderator Moderator<M> ThreadContext next role | END Full thread state

Role ↔ Agent Composition

A Role internally decides how to use the ThreadContext:

  • Simple roles (script/HTTP): serialize start + steps into a prompt string, pass to AgentFn
  • Agent roles (hermes/cursor): pass threadId only, let the agent use nerve thread CLI to progressively load context as needed — more efficient for large threads
function createRole<M>(
  agent: AgentFn,
  identity: { prompt: string; schema: Schema<M> }
): Role<M> {
  return async (ctx) => {
    const fullPrompt = identity.prompt + serializeContext(ctx)
    const raw = await agent(fullPrompt)
    const meta = parse(identity.schema, raw)
    return { content: raw, meta }
  }
}

Key Changes from Current Code

Current Proposed Why
Role(start, messages) Role(ctx: ThreadContext) Unify with Moderator context; add threadId
start.meta.dryRun in Role Adapter config at creation Role doesn't need to know
start.meta.maxRounds in Role Moderator concern only Role doesn't decide when to stop
No AgentFn concept AgentFn = (ThreadContext) => Promise<string> Clean adapter contract; agent decides how to consume context
messages: WorkflowMessage[] steps: RoleStep[] in ThreadContext Consistent naming with ModeratorContext

ThreadContext fields

Field Consumer Usage
threadId Agent roles (hermes/cursor) nerve thread show <id> for progressive loading
start All roles, moderator Prompt, metadata
steps Simple roles, moderator Full history for serialization / routing

Migration

  1. Add AgentFn type + ThreadContext type to @uncaged/nerve-core
  2. Refactor Role<Meta> signature from (start, messages) to (ctx: ThreadContext)
  3. Refactor Moderator to use same ThreadContext (already close — ModeratorContext has { start, steps }; just add threadId)
  4. Move dryRun out of StartStep.meta into adapter/engine config
  5. Update existing workflow definitions
  6. Add createRole(agent, identity) helper to @uncaged/nerve-workflow-utils

Phase Tracking

Phase Issue Status
Phase 1: Core types #268 🔲
Phase 2: workflow-utils #269 🔲
Phase 3: daemon worker #270 🔲
Phase 4: CLI + migration #271 🔲

Non-goals

  • Changing the Moderator routing logic
  • Changing the engine execution loop
  • Changing sense/signal/reflex system
## Summary Refactor the workflow type system to cleanly separate **Agent** (pure execution capability) from **Role** (identity + purpose + structured output). This clarifies the layering and simplifies adapter integration. ## Motivation Current `Role<Meta>` conflates two concerns: 1. **Execution** — calling an LLM / script / HTTP endpoint 2. **Identity** — knowing who you are, what to report, what schema to follow The current signature `(start: StartStep, messages: WorkflowMessage[]) => Promise<RoleResult<Meta>>` leaks engine-level details (maxRounds, dryRun) into every role implementation. Adapter authors (hermes, cursor) have to destructure fields they don't care about. ## Design ### Core Insight ``` Role = Agent + Identity Identity = { prompt, schema } ``` ### New Types ```typescript // Agent: pure text-in text-out execution capability type AgentFn = (ctx: ThreadContext) => Promise<string> // Shared thread context — consumed by both Role and Moderator type ThreadContext<M extends RoleMeta> = { threadId: string start: StartStep steps: RoleStep<M>[] } // Role: identity-bound execution, returns structured output type Role<Meta> = (ctx: ThreadContext) => Promise<RoleResult<Meta>> // Moderator: pure routing, same context type type Moderator<M extends RoleMeta> = (ctx: ThreadContext<M>) => (keyof M & string) | END ``` ### Layering | Layer | Type | Input | Output | Knows about | |-------|------|-------|--------|-------------| | **Agent** | `AgentFn` | `ThreadContext` | `string` | Thread context, but no identity/schema | | **Role** | `Role<M>` | `ThreadContext` | `{ content, meta }` | Identity, schema, thread history | | **Moderator** | `Moderator<M>` | `ThreadContext` | next role \| END | Full thread state | ### Role ↔ Agent Composition A Role internally decides how to use the `ThreadContext`: - **Simple roles** (script/HTTP): serialize `start + steps` into a prompt string, pass to `AgentFn` - **Agent roles** (hermes/cursor): pass `threadId` only, let the agent use `nerve thread` CLI to progressively load context as needed — more efficient for large threads ```typescript function createRole<M>( agent: AgentFn, identity: { prompt: string; schema: Schema<M> } ): Role<M> { return async (ctx) => { const fullPrompt = identity.prompt + serializeContext(ctx) const raw = await agent(fullPrompt) const meta = parse(identity.schema, raw) return { content: raw, meta } } } ``` ### Key Changes from Current Code | Current | Proposed | Why | |---------|----------|-----| | `Role(start, messages)` | `Role(ctx: ThreadContext)` | Unify with Moderator context; add threadId | | `start.meta.dryRun` in Role | Adapter config at creation | Role doesn't need to know | | `start.meta.maxRounds` in Role | Moderator concern only | Role doesn't decide when to stop | | No `AgentFn` concept | `AgentFn = (ThreadContext) => Promise<string>` | Clean adapter contract; agent decides how to consume context | | `messages: WorkflowMessage[]` | `steps: RoleStep[]` in ThreadContext | Consistent naming with ModeratorContext | ### ThreadContext fields | Field | Consumer | Usage | |-------|----------|-------| | `threadId` | Agent roles (hermes/cursor) | `nerve thread show <id>` for progressive loading | | `start` | All roles, moderator | Prompt, metadata | | `steps` | Simple roles, moderator | Full history for serialization / routing | ## Migration 1. Add `AgentFn` type + `ThreadContext` type to `@uncaged/nerve-core` 2. Refactor `Role<Meta>` signature from `(start, messages)` to `(ctx: ThreadContext)` 3. Refactor `Moderator` to use same `ThreadContext` (already close — `ModeratorContext` has `{ start, steps }`; just add `threadId`) 4. Move `dryRun` out of `StartStep.meta` into adapter/engine config 5. Update existing workflow definitions 6. Add `createRole(agent, identity)` helper to `@uncaged/nerve-workflow-utils` ## Phase Tracking | Phase | Issue | Status | |-------|-------|--------| | Phase 1: Core types | #268 | 🔲 | | Phase 2: workflow-utils | #269 | 🔲 | | Phase 3: daemon worker | #270 | 🔲 | | Phase 4: CLI + migration | #271 | 🔲 | ## Non-goals - Changing the Moderator routing logic - Changing the engine execution loop - Changing sense/signal/reflex system
This repo is archived. You cannot comment on issues.
No Label
1 Participants
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: uncaged/nerve#267