Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions middleware/migrations/0004_skill_lifecycle.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- ── skill lifecycle foundation (Wave 0) ─────────────────────────────────────
-- Two provenance/identity columns that unblock the skill import + lifecycle
-- work (#391 / #397):
-- content_hash — deterministic sha256 over the skill's canonical
-- {frontmatter + body}. Drives re-import dedup / convergence (#391) and
-- re-version-on-change (#397). Nullable so pre-existing rows backfill
-- lazily on their next write.
-- forked_from — provenance link set when an imported (source='file') skill
-- is forked into an editable source='db' copy (fork-on-edit, #397). NULL
-- for skills that were never forked.
ALTER TABLE skills ADD COLUMN IF NOT EXISTS content_hash TEXT;
ALTER TABLE skills ADD COLUMN IF NOT EXISTS forked_from UUID REFERENCES skills(id) ON DELETE SET NULL;

CREATE INDEX IF NOT EXISTS skills_content_hash_idx ON skills(content_hash);
CREATE INDEX IF NOT EXISTS skills_forked_from_idx ON skills(forked_from);
15 changes: 15 additions & 0 deletions middleware/migrations/0005_skill_resources.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- ── skill bundle resources (Wave 7) ─────────────────────────────────────────
-- A skill can carry bundled reference files beyond its body (Claude skills ship
-- a folder of resources; #391 extensibility step 2). Each resource is a named
-- text blob owned by a skill and cascade-deleted with it. Kept in its own table
-- (not frontmatter) so resources have their own lifecycle and don't bloat the
-- content_hash. Runtime load-on-demand for sub-agents is a separate step.
CREATE TABLE IF NOT EXISTS skill_resources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
name TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (skill_id, name)
);
CREATE INDEX IF NOT EXISTS skill_resources_skill_idx ON skill_resources(skill_id);
16 changes: 16 additions & 0 deletions middleware/migrations/0006_agent_persona_skills.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- ── agent persona skills (Wave 8) ────────────────────────────────────────────
-- An Agent (Orchestrator) can attach zero or more skills as candidate
-- "direct-answer" personas — skills that shape the TOP-LEVEL orchestrator's
-- own system prompt for a turn, with no sub-agent/tool-call indirection.
-- Distinct from agent_subagents.skill_id (Wave 0/2), which backs a delegated
-- specialist, not the primary chat identity. Pure join table: cascades both
-- ways, no per-link config beyond display ordering.
CREATE TABLE IF NOT EXISTS agent_persona_skills (
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
position INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (agent_id, skill_id)
);
CREATE INDEX IF NOT EXISTS agent_persona_skills_agent_idx ON agent_persona_skills(agent_id);
CREATE INDEX IF NOT EXISTS agent_persona_skills_skill_idx ON agent_persona_skills(skill_id);
15 changes: 15 additions & 0 deletions middleware/packages/harness-channel-sdk/src/chatAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,21 @@ export type ChatStreamEvent =
classifierModel: string;
model: string;
}
/**
* Wave 8 — per-turn direct-answer persona verdict. Emitted ONCE at turn
* start, right after the persona classifier resolves — only when the Agent
* has one or more persona skills attached. `skillId: null` means the
* classifier picked the Agent's default identity (`bucket: 'none'`) or the
* classifier call itself failed (`bucket: 'fallback'`). Lets the UI show
* which persona is answering, the same way `turn_routing` shows the model.
*/
| {
type: 'turn_persona';
bucket: 'matched' | 'none' | 'fallback';
classifierModel: string;
skillId: string | null;
skillName: string | null;
}
| { type: 'text_delta'; text: string }
| {
type: 'tool_use';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import { ChatSessionStore } from './chatSessionStore.js';
import type { Microsoft365Accessor } from './microsoft365-shim.js';
import type { NativeToolRegistry } from './nativeToolRegistry.js';
import type { ModelRoutingConfig } from './modelRouter.js';
import { Orchestrator } from './orchestrator.js';
import { Orchestrator, type OrchestratorPersonaSkill } from './orchestrator.js';
import { CliChatAgent } from './cliChatAgent.js';
import { ToolDispatchService } from './toolDispatchService.js';
import { OrchestratorMemoryNamespacer } from './orchestratorMemoryNamespacer.js';
Expand Down Expand Up @@ -80,6 +80,10 @@ export interface AgentRuntimeConfig {
readonly loopRepeatHard?: number;
/** Optional per-turn wall-clock budget in seconds (0 / omitted = off). */
readonly maxTurnSeconds?: number;
/** Wave 8 — this Agent's direct-answer persona-skill candidates, resolved
* by the caller from `agent_persona_skills` (see {@link OrchestratorOptions}).
* Per-agent, unlike the platform-shared `OrchestratorDeps` fields below. */
readonly personaSkills?: readonly OrchestratorPersonaSkill[];
}

/**
Expand Down Expand Up @@ -242,6 +246,9 @@ export function buildOrchestratorForAgent(
provider: deps.provider,
model: config.model,
...(config.modelRouting ? { modelRouting: config.modelRouting } : {}),
...(config.personaSkills?.length
? { personaSkills: config.personaSkills }
: {}),
maxTokens: config.maxTokens,
maxToolIterations: config.maxToolIterations,
...(config.loopRepeatSoft !== undefined
Expand Down
12 changes: 12 additions & 0 deletions middleware/packages/harness-orchestrator/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ export type {
RoutingBucket,
} from './modelRouter.js';

// Wave 8 — per-turn direct-answer persona routing (twin of modelRouter).
export { routeTurnPersona } from './personaRouter.js';
export type {
PersonaCandidate,
PersonaRouteResult,
PersonaRoutingBucket,
} from './personaRouter.js';

// Multi-orchestrator registry (US4) — read by US7 channel routing and US9 UI.
export {
OrchestratorRegistry,
Expand Down Expand Up @@ -90,14 +98,18 @@ export { runMultiOrchestratorMigrations } from './registry/migrator.js';
// Agent Builder — editable graph store, MCP client, sub-agent materialisation,
// and the persisted-routing → runtime mapping.
export { AgentGraphStore } from './registry/agentGraphStore.js';
export { computeSkillHash } from './registry/skillHash.js';
export type {
CanvasPos,
McpServerInput,
McpServerRow,
PersonaSkillRow,
ScheduleInput,
ScheduleRow,
SkillInput,
SkillPatch,
SkillResourceInput,
SkillResourceRow,
SkillRow,
SubAgentInput,
SubAgentPatch,
Expand Down
137 changes: 122 additions & 15 deletions middleware/packages/harness-orchestrator/src/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ import {
type RoutingBucket,
routeTurnModel,
} from './modelRouter.js';
import {
type PersonaCandidate,
type PersonaRoutingBucket,
routeTurnPersona,
} from './personaRouter.js';
import { fromLlmResponse, toLlmRequest } from './llmProviderSeam.js';
import type {
AnthropicBlock,
Expand Down Expand Up @@ -419,6 +424,22 @@ export interface OrchestratorOptions {
* the concrete agent roster is still rendered live from `domainTools`.
*/
assistantIdentity?: string;
/**
* Wave 8 — skills attached to this Agent as direct-answer persona
* candidates. When non-empty, each turn runs a Haiku classifier
* ({@link routeTurnPersona}) that picks at most one candidate whose `body`
* replaces `assistantIdentity` for that turn only — the rest of the system
* prompt (tool docs, privacy rules, routing block) is unchanged. Absent/
* empty → behaviour is identical to pre-Wave-8 (no classifier call).
*/
personaSkills?: readonly OrchestratorPersonaSkill[];
}

/** A persona candidate resolved with its full body (Orchestrator-internal —
* the classifier itself only ever sees {@link PersonaCandidate}'s cheap
* `name`/`description` fields, never `body`). */
export interface OrchestratorPersonaSkill extends PersonaCandidate {
readonly body: string;
}

// `ChatTurnInput` and `ChatTurnAttachment` were lifted to
Expand Down Expand Up @@ -892,6 +913,8 @@ export class Orchestrator {
private readonly provider: LlmProvider;
private readonly model: string;
private readonly modelRouting: ModelRoutingConfig | undefined;
/** Wave 8 — direct-answer persona candidates; empty when none attached. */
private readonly personaSkills: readonly OrchestratorPersonaSkill[];
private readonly maxTokens: number;
private readonly maxIterations: number;
/** Round-loop guard thresholds (see {@link LoopGuard}). */
Expand Down Expand Up @@ -967,6 +990,7 @@ export class Orchestrator {
this.provider = options.provider;
this.model = options.model;
this.modelRouting = options.modelRouting;
this.personaSkills = options.personaSkills ?? [];
this.maxTokens = options.maxTokens;
this.maxIterations = options.maxToolIterations;
this.loopRepeatSoft = options.loopRepeatSoft;
Expand Down Expand Up @@ -2260,6 +2284,53 @@ export class Orchestrator {
};
}

/**
* Wave 8 — resolves the direct-answer persona for a single turn. With
* persona skills attached, a Haiku classifier picks at most one candidate;
* its `body` should replace `assistantIdentity` for this turn only. Empty
* candidate list short-circuits before any classifier call — an Agent with
* no persona skills pays nothing extra. Never throws — falls back to the
* default identity (`skillBody: undefined`).
*/
private async resolveTurnPersona(userMessage: string): Promise<{
skillBody: string | undefined;
persona?: {
bucket: PersonaRoutingBucket;
classifierModel: string;
skillId: string | null;
skillName: string | null;
};
}> {
if (this.personaSkills.length === 0) return { skillBody: undefined };
const r = await routeTurnPersona(
this.provider,
this.personaSkills,
userMessage,
// Reuses the model-routing classifier tier when configured (same
// Haiku-class model already paid for/warmed). Otherwise falls back to
// `this.model` (this Agent's own production model) rather than a
// hardcoded Anthropic id — `this.provider` may be bound to any vendor
// (OpenAI/Mistral/Ollama/…), and a hardcoded `claude-*` classifier
// model would 404/reject on every call, silently defeating the whole
// feature (every turn falls back to the default identity). `this.model`
// is guaranteed provider-compatible by construction; it costs more
// than a dedicated cheap-tier classifier, but it always works.
this.modelRouting?.classifierModel ?? this.model,
);
const picked = r.skillId
? this.personaSkills.find((p) => p.skillId === r.skillId)
: undefined;
return {
skillBody: picked?.body,
persona: {
bucket: r.bucket,
classifierModel: r.classifierModel,
skillId: picked?.skillId ?? null,
skillName: picked?.name ?? null,
},
};
}

private async chatInContextInner(
input: ChatTurnInput,
turnId: string,
Expand Down Expand Up @@ -2357,11 +2428,19 @@ export class Orchestrator {
let obligationEscalationsUsed = 0;
let forceObligationNext = false;

// Per-turn model routing (no-op unless configured). Resolved once so the
// whole turn — every tool-loop iteration — runs on one model. The
// non-streaming path has no event channel, so the routing decision is
// simply applied (channels that want to surface it use chatStream).
const turnModel = (await this.resolveTurnModel(input.userMessage)).model;
// Per-turn model routing (no-op unless configured) + Wave 8 persona
// routing (no-op unless persona skills are attached), resolved together
// — independent classifier calls, run in parallel so persona routing
// adds no serial latency on top of model routing. Both resolved once so
// the whole turn — every tool-loop iteration — is stable. The
// non-streaming path has no event channel, so both decisions are simply
// applied (channels that want to surface them use chatStream).
const [turnModelResolved, turnPersonaResolved] = await Promise.all([
this.resolveTurnModel(input.userMessage),
this.resolveTurnPersona(input.userMessage),
]);
const turnModel = turnModelResolved.model;
const turnPersonaBody = turnPersonaResolved.skillBody;

try {
for (let iteration = 0; iteration < this.maxIterations; iteration++) {
Expand All @@ -2380,7 +2459,7 @@ export class Orchestrator {
model: turnModel,
max_tokens: this.maxTokens,
system: buildSystemBlocks(
this.composeStableSystemPrompt(prependRules),
this.composeStableSystemPrompt(prependRules, turnPersonaBody),
priorContext,
withFinalizeHint(
effectiveExtraSystemHint,
Expand Down Expand Up @@ -3017,10 +3096,16 @@ export class Orchestrator {
// Mid-turn steering — same key the route enqueues under (see chatStream:
// `sessionId`). Drained at the top of every iteration below.
const steerKey = input.sessionScope ?? turnId;
// Per-turn model routing (no-op unless configured). Resolved once so the
// whole streamed turn runs on one model.
const resolved = await this.resolveTurnModel(input.userMessage);
// Per-turn model routing (no-op unless configured) + Wave 8 persona
// routing (no-op unless persona skills are attached). Independent
// classifier calls — run in parallel so persona routing adds no serial
// latency. Resolved once so the whole streamed turn runs on one model.
const [resolved, resolvedPersona] = await Promise.all([
this.resolveTurnModel(input.userMessage),
this.resolveTurnPersona(input.userMessage),
]);
const turnModel = resolved.model;
const turnPersonaBody = resolvedPersona.skillBody;
// Surface the Haiku-triage decision inline, before the first model call —
// the UI renders it at the top of the turn card so the operator sees the
// classifier's verdict (simple/complex → model) as soon as it lands.
Expand All @@ -3032,6 +3117,18 @@ export class Orchestrator {
model: resolved.routing.model,
};
}
// Wave 8 — surface the persona verdict the same way, only when at least
// one persona skill is attached (resolvedPersona.persona is undefined
// otherwise, matching turn_routing's own "only when configured" shape).
if (resolvedPersona.persona) {
yield {
type: 'turn_persona',
bucket: resolvedPersona.persona.bucket,
classifierModel: resolvedPersona.persona.classifierModel,
skillId: resolvedPersona.persona.skillId,
skillName: resolvedPersona.persona.skillName,
};
}
try {
for (let iteration = 0; iteration < this.maxIterations; iteration++) {
yield { type: 'iteration_start', iteration };
Expand Down Expand Up @@ -3083,7 +3180,7 @@ export class Orchestrator {
model: turnModel,
max_tokens: this.maxTokens,
system: buildSystemBlocks(
this.composeStableSystemPrompt(prependRules),
this.composeStableSystemPrompt(prependRules, turnPersonaBody),
priorContext,
withFinalizeHint(
effectiveExtraSystemHint,
Expand Down Expand Up @@ -3936,8 +4033,14 @@ export class Orchestrator {
* Builds the system prompt from the current DomainTool map. Called per
* turn; stable feature flags come from the readonly fields, the
* DomainTool list is live.
*
* `personaOverride` (Wave 8) replaces `assistantIdentity` for this call
* only — the resolved body of the turn's chosen direct-answer persona
* skill, when one was matched. Every other block (tool docs, privacy
* rules, Fach-Agent routing) is unaffected, so a persona skill can change
* *who* answers but never *how* tools/privacy rules are enforced.
*/
private getSystemPrompt(): string {
private getSystemPrompt(personaOverride?: string): string {
// Plugin-contributed prompt docs, collected from the registry. The
// kernel's hardcoded blocks (graph/diagram/…) remain in buildSystemPrompt
// for their tools; plugin docs land in a separate bullet list so both
Expand All @@ -3947,7 +4050,7 @@ export class Orchestrator {
.map((e) => e.promptDoc)
.filter((doc): doc is string => typeof doc === 'string' && doc.length > 0);
return buildSystemPrompt(
this.assistantIdentity,
personaOverride ?? this.assistantIdentity,
Array.from(this.domainToolsByName.values()),
this.knowledgeGraphTool !== undefined,
// Diagrams is now plugin-contributed — its doc ships via extraDocs.
Expand Down Expand Up @@ -4003,10 +4106,14 @@ export class Orchestrator {
/**
* Combine the Phase-1 prependRules with the body system prompt. Empty
* rules → returns the body unchanged so the prompt-cache key is byte-
* identical to pre-plugin runs.
* identical to pre-plugin runs. `personaOverride` (Wave 8) is threaded
* straight to {@link getSystemPrompt}.
*/
private composeStableSystemPrompt(prependRules: string): string {
const body = this.getSystemPrompt();
private composeStableSystemPrompt(
prependRules: string,
personaOverride?: string,
): string {
const body = this.getSystemPrompt(personaOverride);
if (prependRules.length === 0) return body;
return `${prependRules}\n\n---\n\n${body}`;
}
Expand Down
Loading
Loading