refactor: align package folder names with npm package names
CI / check (pull_request) Failing after 8m30s
CI / check (pull_request) Failing after 8m30s
Rename packages/ subdirectories to match their @united-workforce/* scope: cli-workflow → cli workflow-agent-builtin → agent-builtin workflow-agent-claude-code → agent-claude-code workflow-agent-hermes → agent-hermes workflow-dashboard → dashboard workflow-protocol → protocol workflow-util-agent → util-agent workflow-util → util Updated all tsconfig references, scripts, and active docs. Historical docs (docs/plans/, docs/superpowers/) left as-is. Closes #21
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
# @united-workforce/util-agent
|
||||
|
||||
Agent framework — `createAgent` factory, context builder, frontmatter fast-path, and LLM extract pipeline.
|
||||
|
||||
## Overview
|
||||
|
||||
Layer 2 agent framework. Provides the standard entrypoint for all agent CLIs: parse `<thread-id> <role>` from argv, load thread/workflow context from CAS, invoke the agent's `run`/`continue` functions, validate output via frontmatter fast-path or LLM extract, and write a `StepNodePayload` to CAS.
|
||||
|
||||
Also exports prompt builders, config/storage helpers, and session ID caching for multi-turn agents.
|
||||
|
||||
**Dependencies:** `@ocas/core`, `@ocas/fs`, `@united-workforce/protocol`, `@united-workforce/util`, `dotenv`, `yaml`
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @united-workforce/util-agent
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
All exports come from `src/index.ts`.
|
||||
|
||||
### Agent factory
|
||||
|
||||
```typescript
|
||||
function createAgent(options: AgentOptions): () => Promise<void>
|
||||
|
||||
type AgentOptions = {
|
||||
name: string;
|
||||
run: AgentRunFn;
|
||||
continue: AgentContinueFn;
|
||||
};
|
||||
|
||||
type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
|
||||
type AgentContinueFn = (
|
||||
sessionId: string,
|
||||
message: string,
|
||||
store: AgentContext["store"],
|
||||
) => Promise<AgentRunResult>;
|
||||
|
||||
type AgentRunResult = {
|
||||
output: string;
|
||||
detailHash: string;
|
||||
sessionId: string;
|
||||
};
|
||||
```
|
||||
|
||||
Agent CLIs call `createAgent(...)` and invoke the returned function as `main()`.
|
||||
|
||||
### Context
|
||||
|
||||
```typescript
|
||||
function buildContext(threadId: ThreadId, role: string): Promise<AgentContext>
|
||||
function buildContextWithMeta(
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
): Promise<AgentContext & { meta: BuildContextMeta }>
|
||||
|
||||
type AgentContext = ModeratorContext & {
|
||||
threadId: ThreadId;
|
||||
role: string;
|
||||
store: Store;
|
||||
workflow: WorkflowPayload;
|
||||
outputFormatInstruction: string;
|
||||
edgePrompt: string;
|
||||
isFirstVisit: boolean;
|
||||
};
|
||||
|
||||
type BuildContextMeta = {
|
||||
storageRoot: string;
|
||||
store: Store;
|
||||
schemas: AgentStore["schemas"];
|
||||
headHash: CasRef;
|
||||
chain: ChainState;
|
||||
};
|
||||
```
|
||||
|
||||
Requires `UWF_EDGE_PROMPT` in the environment (set by `uwf thread step`).
|
||||
|
||||
### Prompt builders
|
||||
|
||||
```typescript
|
||||
function buildRolePrompt(role: RoleDefinition): string
|
||||
function buildOutputFormatInstruction(schema: JSONSchema): string
|
||||
function buildContinuationPrompt(
|
||||
steps: StepContext[],
|
||||
role: string,
|
||||
edgePrompt: string,
|
||||
options?: { includeContent?: boolean; quota?: number },
|
||||
): string
|
||||
```
|
||||
|
||||
### Extract pipeline
|
||||
|
||||
```typescript
|
||||
function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias
|
||||
function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider
|
||||
function extract(
|
||||
rawOutput: string,
|
||||
outputSchema: CasRef,
|
||||
config: WorkflowConfig,
|
||||
): Promise<ExtractResult>
|
||||
|
||||
type ResolvedLlmProvider = { baseUrl: string; apiKey: string; model: string };
|
||||
type ExtractResult = { value: unknown; hash: CasRef };
|
||||
```
|
||||
|
||||
### Frontmatter fast-path
|
||||
|
||||
```typescript
|
||||
function tryFrontmatterFastPath(
|
||||
rawOutput: string,
|
||||
outputSchema: CasRef,
|
||||
store: Store,
|
||||
): Promise<FrontmatterFastPathResult | null>
|
||||
|
||||
type FrontmatterFastPathResult = { body: string; outputHash: CasRef };
|
||||
```
|
||||
|
||||
### Session cache
|
||||
|
||||
```typescript
|
||||
function getCachedSessionId(threadId: ThreadId, role: string): Promise<string | null>
|
||||
function setCachedSessionId(
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
sessionId: string,
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
### Config and storage
|
||||
|
||||
```typescript
|
||||
function getConfigPath(storageRoot: string): string
|
||||
function getEnvPath(storageRoot: string): string
|
||||
function resolveStorageRoot(): string
|
||||
function loadWorkflowConfig(storageRoot: string): Promise<WorkflowConfig>
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { createAgent, buildRolePrompt } from "@united-workforce/util-agent";
|
||||
import type { AgentContext, AgentRunResult } from "@united-workforce/util-agent";
|
||||
|
||||
async function run(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
const prompt = buildRolePrompt(ctx.workflow.roles[ctx.role]!);
|
||||
// ... spawn external process, capture output ...
|
||||
return { output: markdown, detailHash: "...", sessionId: "..." };
|
||||
}
|
||||
|
||||
async function continueSession(
|
||||
sessionId: string,
|
||||
message: string,
|
||||
): Promise<AgentRunResult> {
|
||||
// ... continue multi-turn session ...
|
||||
return { output: markdown, detailHash: "...", sessionId };
|
||||
}
|
||||
|
||||
export const main = createAgent({ name: "my-agent", run, continue: continueSession });
|
||||
```
|
||||
|
||||
## Internal Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts
|
||||
├── run.ts createAgent entrypoint
|
||||
├── context.ts Thread chain walk, AgentContext builder
|
||||
├── extract.ts LLM structured extract fallback
|
||||
├── frontmatter.ts Frontmatter fast-path validation
|
||||
├── build-role-prompt.ts Role definition → prompt text
|
||||
├── build-output-format-instruction.ts
|
||||
├── build-continuation-prompt.ts
|
||||
├── session-cache.ts Per-thread/session ID persistence
|
||||
├── storage.ts CAS store, config, threads index
|
||||
├── schemas.ts Agent CAS schema registration
|
||||
└── types.ts AgentContext, AgentOptions, etc.
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Reads `config.yaml` and `.env` from the workflow storage root (`~/.uncaged/workflow` by default). See `@united-workforce/protocol` for `WorkflowConfig` shape. Set via `uwf setup`.
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createMemoryStore, putSchema } from "@ocas/core";
|
||||
|
||||
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const PLANNER_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
$status: { type: "string", enum: ["ready", "failed"] },
|
||||
plan: { type: "string" },
|
||||
},
|
||||
required: ["$status"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
describe("adapter-stdout: A4 retry loop survives JSON output", () => {
|
||||
test("A4. first extraction fails, second succeeds — final result has correct data", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, PLANNER_SCHEMA);
|
||||
|
||||
// Simulate the retry loop from createAgent (run.ts lines 163-173):
|
||||
// First attempt: agent outputs garbage (no frontmatter)
|
||||
const badOutput = "Here is my response without frontmatter.\nJust plain text.";
|
||||
const firstAttempt = await tryFrontmatterFastPath(badOutput, schemaHash, store);
|
||||
expect(firstAttempt).toBeNull();
|
||||
|
||||
// Second attempt (after correction message): agent outputs valid frontmatter
|
||||
const goodOutput = `---\n$status: ready\nplan: corrected-hash\n---\nCorrected body with valid frontmatter.`;
|
||||
const secondAttempt = await tryFrontmatterFastPath(goodOutput, schemaHash, store);
|
||||
|
||||
expect(secondAttempt).not.toBeNull();
|
||||
expect(secondAttempt!.outputHash).toMatch(/^[0-9A-Z]{13}$/);
|
||||
expect(secondAttempt!.frontmatter).toEqual({ $status: "ready", plan: "corrected-hash" });
|
||||
expect(secondAttempt!.body).toBe("Corrected body with valid frontmatter.");
|
||||
|
||||
// Verify the final AdapterOutput shape would be correct
|
||||
const adapterOutput = {
|
||||
stepHash: "MOCK_STEP_HASH",
|
||||
detailHash: "MOCK_DETAIL_HA",
|
||||
role: "planner",
|
||||
frontmatter: secondAttempt!.frontmatter,
|
||||
body: secondAttempt!.body,
|
||||
startedAtMs: 1000,
|
||||
completedAtMs: 2000,
|
||||
assembledPrompt: null,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(adapterOutput);
|
||||
const parsed = JSON.parse(json);
|
||||
expect(parsed.frontmatter).toEqual({ $status: "ready", plan: "corrected-hash" });
|
||||
expect(parsed.body).toBe("Corrected body with valid frontmatter.");
|
||||
expect(parsed.completedAtMs).toBeGreaterThanOrEqual(parsed.startedAtMs);
|
||||
});
|
||||
|
||||
test("A4. all retries fail — extraction returns null on every attempt", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, PLANNER_SCHEMA);
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
const badOutput = "No frontmatter here";
|
||||
|
||||
// Simulate MAX_FRONTMATTER_RETRIES iterations all failing
|
||||
let extracted = await tryFrontmatterFastPath(badOutput, schemaHash, store);
|
||||
for (let retry = 0; retry < MAX_RETRIES && extracted === null; retry++) {
|
||||
// Each retry also gets bad output
|
||||
extracted = await tryFrontmatterFastPath(badOutput, schemaHash, store);
|
||||
}
|
||||
|
||||
expect(extracted).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createMemoryStore, putSchema } from "@ocas/core";
|
||||
|
||||
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const PLANNER_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
$status: { type: "string", enum: ["ready", "failed"] },
|
||||
plan: { type: "string" },
|
||||
},
|
||||
required: ["$status"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const FRONTMATTER_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
next: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
confidence: { anyOf: [{ type: "number" }, { type: "null" }] },
|
||||
artifacts: { type: "array", items: { type: "string" } },
|
||||
scope: { type: "string" },
|
||||
},
|
||||
required: ["status", "next", "confidence", "artifacts", "scope"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
describe("adapter-stdout: FrontmatterFastPathResult includes frontmatter", () => {
|
||||
test("A2. frontmatter field contains the parsed YAML frontmatter object", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, PLANNER_SCHEMA);
|
||||
|
||||
const raw = `---\n$status: ready\nplan: abc123\n---\nSome body text`;
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.frontmatter).toEqual({ $status: "ready", plan: "abc123" });
|
||||
});
|
||||
|
||||
test("A3. body field contains the markdown body after frontmatter", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, PLANNER_SCHEMA);
|
||||
|
||||
const raw = `---\n$status: ready\nplan: hash123\n---\nHere is the body.\n\nWith multiple paragraphs.`;
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.body).toBe("Here is the body.\n\nWith multiple paragraphs.");
|
||||
});
|
||||
|
||||
test("A1. result contains outputHash as valid CasRef", async () => {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, FRONTMATTER_SCHEMA);
|
||||
|
||||
const raw = `---\nstatus: done\nnext: null\nconfidence: 0.9\nartifacts: []\nscope: test\n---\nBody`;
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.outputHash).toMatch(/^[0-9A-Z]{13}$/);
|
||||
expect(result!.frontmatter).toBeDefined();
|
||||
expect(result!.body).toBe("Body");
|
||||
});
|
||||
});
|
||||
|
||||
describe("adapter-stdout: AdapterOutput JSON shape", () => {
|
||||
test("A5. JSON.stringify produces valid parseable JSON with all fields", () => {
|
||||
const output = {
|
||||
stepHash: "0123456789ABC",
|
||||
detailHash: "DEFGH12345678",
|
||||
role: "planner",
|
||||
frontmatter: { $status: "ready", plan: "somehash" },
|
||||
body: "Plan body text",
|
||||
startedAtMs: 1000,
|
||||
completedAtMs: 2000,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(output);
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
expect(parsed.stepHash).toBe("0123456789ABC");
|
||||
expect(parsed.detailHash).toBe("DEFGH12345678");
|
||||
expect(parsed.role).toBe("planner");
|
||||
expect(parsed.frontmatter).toEqual({ $status: "ready", plan: "somehash" });
|
||||
expect(parsed.body).toBe("Plan body text");
|
||||
expect(parsed.startedAtMs).toBe(1000);
|
||||
expect(parsed.completedAtMs).toBe(2000);
|
||||
});
|
||||
|
||||
test("completedAtMs >= startedAtMs", () => {
|
||||
const output = {
|
||||
stepHash: "0123456789ABC",
|
||||
detailHash: "DEFGH12345678",
|
||||
role: "planner",
|
||||
frontmatter: {},
|
||||
body: "",
|
||||
startedAtMs: 1000,
|
||||
completedAtMs: 2000,
|
||||
};
|
||||
|
||||
expect(output.completedAtMs).toBeGreaterThanOrEqual(output.startedAtMs);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { StepContext } from "@united-workforce/protocol";
|
||||
import { buildContinuationPrompt } from "../src/build-continuation-prompt.js";
|
||||
|
||||
const reviewerStep: StepContext = {
|
||||
role: "reviewer",
|
||||
output: { approved: false, comments: "Missing tests" },
|
||||
detail: "2MXBG6PN4A8JR",
|
||||
agent: "uwf-hermes",
|
||||
edgePrompt: "Review the developer's work.",
|
||||
content: null,
|
||||
};
|
||||
|
||||
const developerStep: StepContext = {
|
||||
role: "developer",
|
||||
output: { filesChanged: ["src/app.ts"], summary: "Initial fix" },
|
||||
detail: "1VPBG9SM5E7WK",
|
||||
agent: "uwf-hermes",
|
||||
edgePrompt: "Implement the fix.",
|
||||
content: null,
|
||||
};
|
||||
|
||||
describe("buildContinuationPrompt", () => {
|
||||
test("includes steps after the last matching role and the edge prompt", () => {
|
||||
const steps: StepContext[] = [
|
||||
developerStep,
|
||||
reviewerStep,
|
||||
{
|
||||
role: "planner",
|
||||
output: { plan: "revise approach" },
|
||||
detail: "7BQST3VW9F2MA",
|
||||
agent: "uwf-hermes",
|
||||
edgePrompt: "Revise the plan.",
|
||||
content: null,
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildContinuationPrompt(
|
||||
steps,
|
||||
"developer",
|
||||
"The reviewer rejected your implementation. Read their feedback and fix the issues.",
|
||||
);
|
||||
|
||||
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||
expect(result).toContain("### Step 2: reviewer");
|
||||
expect(result).toContain("Missing tests");
|
||||
expect(result).toContain("### Step 3: planner");
|
||||
expect(result).toContain("## Moderator Instruction");
|
||||
expect(result).toContain("The reviewer rejected your implementation.");
|
||||
expect(result).not.toContain("Initial fix");
|
||||
});
|
||||
|
||||
test("uses all steps when the role has not run before", () => {
|
||||
const result = buildContinuationPrompt(
|
||||
[developerStep, reviewerStep],
|
||||
"planner",
|
||||
"Continue from the reviewer feedback.",
|
||||
);
|
||||
|
||||
expect(result).toContain("### Step 1: developer");
|
||||
expect(result).toContain("### Step 2: reviewer");
|
||||
expect(result).toContain("Continue from the reviewer feedback.");
|
||||
});
|
||||
|
||||
test("still includes moderator instruction when there are no intervening steps", () => {
|
||||
const result = buildContinuationPrompt(
|
||||
[developerStep],
|
||||
"developer",
|
||||
"Please revise your work.",
|
||||
);
|
||||
|
||||
expect(result).not.toContain("## What Happened Since Your Last Turn");
|
||||
expect(result).toContain("## Moderator Instruction");
|
||||
expect(result).toContain("Please revise your work.");
|
||||
});
|
||||
|
||||
test("includes step content when includeContent option is true", () => {
|
||||
const stepsWithContent: StepContext[] = [
|
||||
{
|
||||
role: "planner",
|
||||
output: { plan: "hash123" },
|
||||
detail: "detail1",
|
||||
agent: "uwf-hermes",
|
||||
edgePrompt: "",
|
||||
content: "# Plan\nDetailed plan markdown...",
|
||||
},
|
||||
{
|
||||
role: "developer",
|
||||
output: { filesChanged: ["app.ts"] },
|
||||
detail: "detail2",
|
||||
agent: "uwf-hermes",
|
||||
edgePrompt: "",
|
||||
content: "# Implementation\nCode changes...",
|
||||
},
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: false },
|
||||
detail: "detail3",
|
||||
agent: "uwf-hermes",
|
||||
edgePrompt: "",
|
||||
content: "# Review\nFeedback...",
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildContinuationPrompt(stepsWithContent, "committer", "Commit the changes.", {
|
||||
includeContent: true,
|
||||
});
|
||||
|
||||
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||
expect(result).toContain("### Step 1: planner");
|
||||
expect(result).toContain("#### Step Content");
|
||||
expect(result).toContain("# Plan");
|
||||
expect(result).toContain("Detailed plan markdown");
|
||||
expect(result).toContain("### Step 2: developer");
|
||||
expect(result).toContain("# Implementation");
|
||||
expect(result).toContain("### Step 3: reviewer");
|
||||
expect(result).toContain("# Review");
|
||||
expect(result).toContain("## Moderator Instruction");
|
||||
expect(result).toContain("Commit the changes.");
|
||||
});
|
||||
|
||||
test("omits step content when includeContent is false (default)", () => {
|
||||
const stepsWithContent: StepContext[] = [
|
||||
{
|
||||
role: "developer",
|
||||
output: { filesChanged: ["app.ts"] },
|
||||
detail: "detail1",
|
||||
agent: "uwf-hermes",
|
||||
edgePrompt: "",
|
||||
content: "# Implementation\nCode changes...",
|
||||
},
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: false },
|
||||
detail: "detail2",
|
||||
agent: "uwf-hermes",
|
||||
edgePrompt: "",
|
||||
content: "# Review\nFeedback...",
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildContinuationPrompt(stepsWithContent, "developer", "Fix the issues.");
|
||||
|
||||
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||
expect(result).toContain("### Step 2: reviewer");
|
||||
expect(result).toContain(JSON.stringify(stepsWithContent[1]?.output));
|
||||
expect(result).not.toContain("#### Step Content");
|
||||
expect(result).not.toContain("# Review");
|
||||
});
|
||||
|
||||
test("respects quota when includeContent is true", () => {
|
||||
const largeContent = "x".repeat(5000);
|
||||
const stepsWithContent: StepContext[] = [
|
||||
{
|
||||
role: "planner",
|
||||
output: { plan: "hash1" },
|
||||
detail: "detail1",
|
||||
agent: "uwf-hermes",
|
||||
edgePrompt: "",
|
||||
content: largeContent,
|
||||
},
|
||||
{
|
||||
role: "developer",
|
||||
output: { files: ["app.ts"] },
|
||||
detail: "detail2",
|
||||
agent: "uwf-hermes",
|
||||
edgePrompt: "",
|
||||
content: largeContent,
|
||||
},
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: true },
|
||||
detail: "detail3",
|
||||
agent: "uwf-hermes",
|
||||
edgePrompt: "",
|
||||
content: "# Review\nLooks good!",
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildContinuationPrompt(stepsWithContent, "committer", "Commit the changes.", {
|
||||
includeContent: true,
|
||||
quota: 1000,
|
||||
});
|
||||
|
||||
// Should include most recent step(s) within quota
|
||||
expect(result).toContain("### Step 1: reviewer"); // Showing 1 of 3, so step 3 becomes step 1
|
||||
expect(result).toContain("#### Step Content");
|
||||
expect(result).toContain("## Moderator Instruction");
|
||||
expect(result).toContain("Showing 1 of 3 steps (2 omitted due to quota)");
|
||||
});
|
||||
|
||||
test("handles null content gracefully when includeContent is true", () => {
|
||||
const stepsWithMixedContent: StepContext[] = [
|
||||
{
|
||||
role: "planner",
|
||||
output: { plan: "hash1" },
|
||||
detail: "detail1",
|
||||
agent: "uwf-hermes",
|
||||
edgePrompt: "",
|
||||
content: "# Plan\nDetails...",
|
||||
},
|
||||
{
|
||||
role: "developer",
|
||||
output: { files: ["app.ts"] },
|
||||
detail: "detail2",
|
||||
agent: "uwf-hermes",
|
||||
edgePrompt: "",
|
||||
content: null, // No content available
|
||||
},
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: true },
|
||||
detail: "detail3",
|
||||
agent: "uwf-hermes",
|
||||
edgePrompt: "",
|
||||
content: "# Review\nApproved!",
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildContinuationPrompt(
|
||||
stepsWithMixedContent,
|
||||
"committer",
|
||||
"Commit the changes.",
|
||||
{ includeContent: true },
|
||||
);
|
||||
|
||||
expect(result).toContain("### Step 1: planner");
|
||||
expect(result).toContain("# Plan");
|
||||
expect(result).toContain("### Step 2: developer");
|
||||
// Step 2 should not have content section since content is null
|
||||
expect(result).toContain("### Step 3: reviewer");
|
||||
expect(result).toContain("# Review");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { buildOutputFormatInstruction } from "../src/build-output-format-instruction.js";
|
||||
|
||||
const PLANNER_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["ready", "insufficient_info"] },
|
||||
plan: { type: "string" },
|
||||
},
|
||||
required: ["status"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const REVIEWER_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
approved: { type: "boolean" },
|
||||
},
|
||||
required: ["approved"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
describe("buildOutputFormatInstruction", () => {
|
||||
test("always includes the frontmatter example block", () => {
|
||||
const result = buildOutputFormatInstruction({});
|
||||
expect(result).toContain("---");
|
||||
expect(result).not.toContain("status: done");
|
||||
expect(result).not.toContain("confidence:");
|
||||
expect(result).not.toContain("scope: role");
|
||||
});
|
||||
|
||||
test("always marks frontmatter as the primary deliverable", () => {
|
||||
const result = buildOutputFormatInstruction({});
|
||||
expect(result).toContain("primary deliverable");
|
||||
});
|
||||
|
||||
test("generates planner-specific YAML example from schema", () => {
|
||||
const result = buildOutputFormatInstruction(PLANNER_SCHEMA);
|
||||
expect(result).toContain("status: ready # required | ready | insufficient_info");
|
||||
expect(result).toContain("plan: <string>");
|
||||
expect(result).not.toContain("status: done");
|
||||
expect(result).not.toContain("confidence:");
|
||||
expect(result).not.toContain("artifacts:");
|
||||
});
|
||||
|
||||
test("generates reviewer-specific YAML example from schema", () => {
|
||||
const result = buildOutputFormatInstruction(REVIEWER_SCHEMA);
|
||||
expect(result).toContain("approved: true # required | true | false");
|
||||
expect(result).not.toContain("status:");
|
||||
});
|
||||
|
||||
test("lists fields from a flat object schema with required marker", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string" },
|
||||
confidence: { type: "number" },
|
||||
},
|
||||
required: ["status"],
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`status` (required)");
|
||||
expect(result).toContain("`confidence`");
|
||||
expect(result).not.toContain("`confidence` (required)");
|
||||
expect(result).toContain("status: <string> # required");
|
||||
expect(result).toContain("confidence: <number>");
|
||||
});
|
||||
|
||||
test("lists union of fields from an anyOf schema", () => {
|
||||
const schema = {
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { alpha: { type: "string" } },
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { beta: { type: "number" } },
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`alpha`");
|
||||
expect(result).toContain("`beta`");
|
||||
expect(result).toContain("alpha: <string>");
|
||||
expect(result).toContain("beta: <number>");
|
||||
});
|
||||
|
||||
test("lists union of fields from a oneOf schema (no discriminant — flat merge)", () => {
|
||||
const schema = {
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { foo: { type: "string" } },
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { bar: { type: "boolean" } },
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
// No discriminant detected → falls back to flat merge
|
||||
expect(result).toContain("`foo`");
|
||||
expect(result).toContain("`bar`");
|
||||
expect(result).toContain("foo: <string>");
|
||||
expect(result).toContain("bar: true # true | false");
|
||||
});
|
||||
|
||||
test("renders per-variant instructions for discriminated oneOf", () => {
|
||||
const schema = {
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
$status: { const: "ready" },
|
||||
plan: { type: "string" },
|
||||
},
|
||||
required: ["$status", "plan"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
$status: { const: "insufficient_info" },
|
||||
},
|
||||
required: ["$status"],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("Choose ONE of the following variants");
|
||||
expect(result).toContain("When `$status: ready`");
|
||||
expect(result).toContain("When `$status: insufficient_info`");
|
||||
expect(result).toContain("plan: <string>");
|
||||
// The insufficient_info variant should NOT mention plan
|
||||
const insufficientBlock = result.split("When `$status: insufficient_info`")[1];
|
||||
expect(insufficientBlock).not.toContain("plan:");
|
||||
});
|
||||
|
||||
test("renders per-variant for single-enum discriminant", () => {
|
||||
const schema = {
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
$status: { type: "string", enum: ["approved"] },
|
||||
branch: { type: "string" },
|
||||
},
|
||||
required: ["$status"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
$status: { type: "string", enum: ["rejected"] },
|
||||
comments: { type: "string" },
|
||||
},
|
||||
required: ["$status"],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("When `$status: approved`");
|
||||
expect(result).toContain("When `$status: rejected`");
|
||||
expect(result).toContain("branch: <string>");
|
||||
expect(result).toContain("comments: <string>");
|
||||
});
|
||||
|
||||
test("falls back gracefully for a non-object schema with no properties", () => {
|
||||
const result = buildOutputFormatInstruction({ type: "string" });
|
||||
expect(result).toContain("schema fields will be extracted automatically");
|
||||
});
|
||||
|
||||
test("does not list a field more than once for a union with overlapping keys", () => {
|
||||
const schema = {
|
||||
anyOf: [
|
||||
{ type: "object", properties: { shared: { type: "string" } } },
|
||||
{ type: "object", properties: { shared: { type: "number" } } },
|
||||
],
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
const matches = [...result.matchAll(/`shared`/g)];
|
||||
expect(matches.length).toBe(1);
|
||||
expect(result).toContain("shared: <string>");
|
||||
});
|
||||
|
||||
test("marks required when any union variant requires the field", () => {
|
||||
const schema = {
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { shared: { type: "string" } },
|
||||
required: ["shared"],
|
||||
},
|
||||
{ type: "object", properties: { shared: { type: "number" } } },
|
||||
],
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`shared` (required)");
|
||||
expect(result).toContain("shared: <string> # required");
|
||||
});
|
||||
|
||||
test("explicitly forbids extra frontmatter fields", () => {
|
||||
const result = buildOutputFormatInstruction(PLANNER_SCHEMA);
|
||||
expect(result).toMatch(/\b(only|exclusively)\b.*fields/i);
|
||||
expect(result).toMatch(/do not add (extra|additional|other) fields/i);
|
||||
});
|
||||
|
||||
test("forbids extra fields even for empty schema", () => {
|
||||
const result = buildOutputFormatInstruction({});
|
||||
expect(result).toMatch(/do not add (extra|additional|other) fields/i);
|
||||
});
|
||||
|
||||
test("forbids extra fields for anyOf/oneOf schemas", () => {
|
||||
const schema = {
|
||||
anyOf: [
|
||||
{ type: "object", properties: { alpha: { type: "string" } } },
|
||||
{ type: "object", properties: { beta: { type: "number" } } },
|
||||
],
|
||||
};
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toMatch(/do not add (extra|additional|other) fields/i);
|
||||
});
|
||||
|
||||
test("includes focus reminder about role scope", () => {
|
||||
const result = buildOutputFormatInstruction({});
|
||||
expect(result).toContain("Focus exclusively on YOUR role");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { RoleDefinition } from "@united-workforce/protocol";
|
||||
import { buildRolePrompt } from "../src/build-role-prompt.js";
|
||||
|
||||
describe("buildRolePrompt", () => {
|
||||
test("all fields present", () => {
|
||||
const role: RoleDefinition = {
|
||||
description: "A coder",
|
||||
goal: "You are a senior developer.",
|
||||
capabilities: ["cursor-agent", "file-edit"],
|
||||
procedure: "Implement the feature.",
|
||||
output: "Summarize changes.",
|
||||
meta: "placeholder00000" as string,
|
||||
};
|
||||
const result = buildRolePrompt(role);
|
||||
expect(result).toContain("## Goal");
|
||||
expect(result).toContain("You are a senior developer.");
|
||||
expect(result).toContain("## Capabilities");
|
||||
expect(result).toContain("- cursor-agent");
|
||||
expect(result).toContain("- file-edit");
|
||||
expect(result).toContain("## Prepare");
|
||||
expect(result).toContain("uwf CLI Reference");
|
||||
expect(result).toContain("cursor-agent, file-edit");
|
||||
expect(result).toContain("## Procedure");
|
||||
expect(result).toContain("Implement the feature.");
|
||||
expect(result).toContain("## Output");
|
||||
expect(result).toContain("Summarize changes.");
|
||||
});
|
||||
|
||||
test("empty fields are omitted but Prepare is always present", () => {
|
||||
const role: RoleDefinition = {
|
||||
description: "A reviewer",
|
||||
goal: "You are a code reviewer.",
|
||||
capabilities: [],
|
||||
procedure: "Review the PR diff carefully.",
|
||||
output: "",
|
||||
meta: "placeholder00000" as string,
|
||||
};
|
||||
const result = buildRolePrompt(role);
|
||||
expect(result).toContain("## Goal");
|
||||
expect(result).toContain("## Prepare");
|
||||
expect(result).toContain("uwf CLI Reference");
|
||||
expect(result).toContain("## Procedure");
|
||||
expect(result).not.toContain("## Capabilities");
|
||||
expect(result).not.toContain("## Output");
|
||||
});
|
||||
|
||||
test("all empty still includes Prepare section", () => {
|
||||
const role: RoleDefinition = {
|
||||
description: "Minimal",
|
||||
goal: "",
|
||||
capabilities: [],
|
||||
procedure: "",
|
||||
output: "",
|
||||
meta: "placeholder00000" as string,
|
||||
};
|
||||
const result = buildRolePrompt(role);
|
||||
expect(result).toContain("## Prepare");
|
||||
expect(result).toContain("uwf CLI Reference");
|
||||
expect(result).not.toContain("## Goal");
|
||||
expect(result).not.toContain("## Capabilities");
|
||||
expect(result).not.toContain("## Procedure");
|
||||
expect(result).not.toContain("## Output");
|
||||
});
|
||||
|
||||
test("capabilities rendered as bullet list", () => {
|
||||
const role: RoleDefinition = {
|
||||
description: "Agent",
|
||||
goal: "",
|
||||
capabilities: ["search", "code", "browse"],
|
||||
procedure: "",
|
||||
output: "",
|
||||
meta: "placeholder00000" as string,
|
||||
};
|
||||
const result = buildRolePrompt(role);
|
||||
expect(result).toContain("## Capabilities");
|
||||
expect(result).toContain("- search");
|
||||
expect(result).toContain("- code");
|
||||
expect(result).toContain("- browse");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
// We need to test buildHistory indirectly through buildContext
|
||||
// since buildHistory is not exported. For now, we'll test the integration
|
||||
// through the public API in a separate integration test.
|
||||
|
||||
describe("context module - content extraction", () => {
|
||||
test("placeholder - content extraction will be tested via integration tests", () => {
|
||||
// This test is a placeholder. The actual testing of content extraction
|
||||
// will be done through integration tests in build-continuation-prompt.test.ts
|
||||
// where we can verify that StepContext objects have the correct content field.
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createMemoryStore, putSchema } from "@ocas/core";
|
||||
|
||||
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** JSON Schema that matches the new status-only AgentFrontmatter. */
|
||||
const STATUS_ONLY_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
},
|
||||
required: ["status"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
/** JSON Schema that requires a non-frontmatter field — fast path must not satisfy it. */
|
||||
const STRICT_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
requiredField: { type: "string" },
|
||||
},
|
||||
required: ["requiredField"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
/** Role-specific schema (reviewer) — only approved, no standard agent fields. */
|
||||
const REVIEWER_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
approved: { type: "boolean" },
|
||||
},
|
||||
required: ["approved"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
/** Role-specific schema (planner) — custom status enum + plan hash. */
|
||||
const PLANNER_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["ready", "insufficient_info"] },
|
||||
plan: { type: "string" },
|
||||
},
|
||||
required: ["status"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
async function makeStoreWithSchema(schema: Record<string, unknown>) {
|
||||
const store = createMemoryStore();
|
||||
const schemaHash = await putSchema(store, schema);
|
||||
return { store, schemaHash };
|
||||
}
|
||||
|
||||
// ── STANDARD_KEYS ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("STANDARD_KEYS contains only status", () => {
|
||||
test("STANDARD_KEYS is ['status']", async () => {
|
||||
// We verify indirectly: defaultCandidate (no schema fields) returns only { status }
|
||||
const { store, schemaHash } = await makeStoreWithSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
},
|
||||
});
|
||||
|
||||
const raw = "---\nstatus: done\n---\n\nBody.";
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
const node = store.get(result!.outputHash);
|
||||
expect(node).not.toBeNull();
|
||||
const payload = node!.payload as Record<string, unknown>;
|
||||
expect(payload.status).toBe("done");
|
||||
// Legacy fields must NOT be present
|
||||
expect(payload.next).toBeUndefined();
|
||||
expect(payload.confidence).toBeUndefined();
|
||||
expect(payload.artifacts).toBeUndefined();
|
||||
expect(payload.scope).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Happy path ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("tryFrontmatterFastPath — happy path", () => {
|
||||
test("parses valid frontmatter and returns outputHash + stripped body", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(STATUS_ONLY_SCHEMA);
|
||||
|
||||
const raw = ["---", "status: done", "---", "", "## Summary", "Work is complete."].join("\n");
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.body).toContain("## Summary");
|
||||
expect(result?.body).toContain("Work is complete.");
|
||||
expect(result?.body).not.toContain("status: done");
|
||||
expect(typeof result?.outputHash).toBe("string");
|
||||
expect((result?.outputHash ?? "").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("stored CAS node payload has only status", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(STATUS_ONLY_SCHEMA);
|
||||
|
||||
const raw = "---\nstatus: done\n---\n\nBody.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
const node = store.get(result!.outputHash);
|
||||
expect(node).not.toBeNull();
|
||||
const payload = node!.payload as Record<string, unknown>;
|
||||
expect(payload.status).toBe("done");
|
||||
expect(Object.keys(payload)).toEqual(["status"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Legacy fields in input are ignored ──────────────────────────────────────
|
||||
|
||||
describe("tryFrontmatterFastPath — legacy fields ignored", () => {
|
||||
test("legacy fields in input do not appear in CAS output", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(STATUS_ONLY_SCHEMA);
|
||||
|
||||
const raw =
|
||||
"---\nstatus: done\nnext: reviewer\nconfidence: 0.9\nartifacts: [a.ts]\nscope: thread\n---\n\nBody.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
const node = store.get(result!.outputHash);
|
||||
const payload = node!.payload as Record<string, unknown>;
|
||||
expect(payload.status).toBe("done");
|
||||
expect(payload.next).toBeUndefined();
|
||||
expect(payload.confidence).toBeUndefined();
|
||||
expect(payload.artifacts).toBeUndefined();
|
||||
expect(payload.scope).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Fallback: no frontmatter ───────────────────────────────────────────────────
|
||||
|
||||
describe("tryFrontmatterFastPath — fallback: no frontmatter", () => {
|
||||
test("returns null for plain markdown without frontmatter block", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(STATUS_ONLY_SCHEMA);
|
||||
|
||||
const result = await tryFrontmatterFastPath(
|
||||
"This is plain markdown without any frontmatter.",
|
||||
schemaHash,
|
||||
store,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Fallback: schema mismatch ─────────────────────────────────────────────────
|
||||
|
||||
describe("tryFrontmatterFastPath — fallback: schema mismatch", () => {
|
||||
test("returns null when outputSchema requires fields not in frontmatter", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(STRICT_SCHEMA);
|
||||
|
||||
const raw = "---\nstatus: done\n---\n\nBody.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Role-specific schema fields ───────────────────────────────────────────────
|
||||
|
||||
describe("tryFrontmatterFastPath — role-specific fields", () => {
|
||||
test("extracts approved only for reviewer schema (no extra standard fields)", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(REVIEWER_SCHEMA);
|
||||
|
||||
const raw = "---\napproved: true\n---\n\nReview passed.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
const node = store.get(result!.outputHash);
|
||||
expect(node).not.toBeNull();
|
||||
const payload = node!.payload as Record<string, unknown>;
|
||||
expect(payload).toEqual({ approved: true });
|
||||
expect(payload.status).toBeUndefined();
|
||||
expect(payload.scope).toBeUndefined();
|
||||
});
|
||||
|
||||
test("extracts plan and role-specific status for planner schema", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(PLANNER_SCHEMA);
|
||||
|
||||
const raw = "---\nstatus: ready\nplan: 01HASHPLANNER0001\n---\n\nSpec summary.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
const node = store.get(result!.outputHash);
|
||||
expect(node).not.toBeNull();
|
||||
const payload = node!.payload as Record<string, unknown>;
|
||||
expect(payload.status).toBe("ready");
|
||||
expect(payload.plan).toBe("01HASHPLANNER0001");
|
||||
expect(payload.scope).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns null when required role-specific field is missing", async () => {
|
||||
const { store, schemaHash } = await makeStoreWithSchema(REVIEWER_SCHEMA);
|
||||
|
||||
const raw = "---\nstatus: done\n---\n\nBody.";
|
||||
|
||||
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { WorkflowConfig } from "@united-workforce/protocol";
|
||||
import { resolveExtractModelAlias } from "../src/extract.js";
|
||||
|
||||
function baseConfig(overrides: Partial<WorkflowConfig> = {}): WorkflowConfig {
|
||||
return {
|
||||
providers: {},
|
||||
models: {
|
||||
sonnet: { provider: "openrouter", name: "anthropic/claude-sonnet-4" },
|
||||
"gpt4o-mini": { provider: "openai", name: "gpt-4o-mini" },
|
||||
},
|
||||
agents: {},
|
||||
defaultAgent: "hermes",
|
||||
agentOverrides: null,
|
||||
defaultModel: "sonnet",
|
||||
modelOverrides: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveExtractModelAlias", () => {
|
||||
test("uses modelOverrides.extract when set", () => {
|
||||
const config = baseConfig({
|
||||
modelOverrides: { extract: "gpt4o-mini" },
|
||||
});
|
||||
expect(resolveExtractModelAlias(config)).toBe("gpt4o-mini");
|
||||
});
|
||||
|
||||
test("falls back to models.extract alias when present", () => {
|
||||
const config = baseConfig({
|
||||
models: {
|
||||
extract: { provider: "openai", name: "gpt-4o-mini" },
|
||||
sonnet: { provider: "openrouter", name: "anthropic/claude-sonnet-4" },
|
||||
},
|
||||
});
|
||||
expect(resolveExtractModelAlias(config)).toBe("extract");
|
||||
});
|
||||
|
||||
test("falls back to defaultModel", () => {
|
||||
expect(resolveExtractModelAlias(baseConfig())).toBe("sonnet");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { ThreadId } from "@united-workforce/protocol";
|
||||
|
||||
import { getCachedSessionId, getCachePath, setCachedSessionId } from "../src/session-cache.js";
|
||||
import { resolveStorageRoot } from "../src/storage.js";
|
||||
|
||||
describe("session-cache", () => {
|
||||
let originalStorageRoot: string;
|
||||
let testStorageRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a temporary test storage root
|
||||
originalStorageRoot = resolveStorageRoot();
|
||||
testStorageRoot = join(originalStorageRoot, "test-cache", `test-${Date.now()}`);
|
||||
await mkdir(testStorageRoot, { recursive: true });
|
||||
|
||||
// Override the storage root for testing
|
||||
process.env.WORKFLOW_STORAGE_ROOT = testStorageRoot;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test storage root
|
||||
await rm(testStorageRoot, { recursive: true, force: true });
|
||||
delete process.env.WORKFLOW_STORAGE_ROOT;
|
||||
});
|
||||
|
||||
describe("getCachePath", () => {
|
||||
test("returns agent-specific file path", () => {
|
||||
const path = getCachePath("claude-code");
|
||||
expect(path).toMatch(/\/cache\/claude-code-sessions\.json$/);
|
||||
});
|
||||
|
||||
test("returns different paths for different agents", () => {
|
||||
const pathClaudeCode = getCachePath("claude-code");
|
||||
const pathHermes = getCachePath("hermes");
|
||||
|
||||
expect(pathClaudeCode).not.toBe(pathHermes);
|
||||
expect(pathClaudeCode).toMatch(/claude-code-sessions\.json$/);
|
||||
expect(pathHermes).toMatch(/hermes-sessions\.json$/);
|
||||
});
|
||||
|
||||
test("handles agent names with special characters", () => {
|
||||
const path1 = getCachePath("my-agent");
|
||||
const path2 = getCachePath("my_agent");
|
||||
|
||||
expect(path1).toMatch(/my-agent-sessions\.json$/);
|
||||
expect(path2).toMatch(/my_agent-sessions\.json$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session isolation", () => {
|
||||
const threadId = "01234567890123456789012345" as ThreadId;
|
||||
const role = "developer";
|
||||
|
||||
test("sessions are isolated per agent", async () => {
|
||||
// Cache different session IDs for each agent
|
||||
await setCachedSessionId("claude-code", threadId, role, "session-cc-001");
|
||||
await setCachedSessionId("hermes", threadId, role, "session-hermes-001");
|
||||
|
||||
// Each agent should retrieve its own session ID
|
||||
const sessionCC = await getCachedSessionId("claude-code", threadId, role);
|
||||
const sessionHermes = await getCachedSessionId("hermes", threadId, role);
|
||||
|
||||
expect(sessionCC).toBe("session-cc-001");
|
||||
expect(sessionHermes).toBe("session-hermes-001");
|
||||
});
|
||||
|
||||
test("updating one agent's cache does not affect another", async () => {
|
||||
// Set initial sessions for both agents
|
||||
await setCachedSessionId("claude-code", threadId, role, "session-cc-001");
|
||||
await setCachedSessionId("hermes", threadId, role, "session-hermes-001");
|
||||
|
||||
// Update claude-code's session
|
||||
await setCachedSessionId("claude-code", threadId, role, "session-cc-002");
|
||||
|
||||
// Hermes's session should remain unchanged
|
||||
const sessionHermes = await getCachedSessionId("hermes", threadId, role);
|
||||
expect(sessionHermes).toBe("session-hermes-001");
|
||||
|
||||
// Claude-code should have the new session
|
||||
const sessionCC = await getCachedSessionId("claude-code", threadId, role);
|
||||
expect(sessionCC).toBe("session-cc-002");
|
||||
});
|
||||
|
||||
test("missing session returns null for specific agent", async () => {
|
||||
const session = await getCachedSessionId("claude-code", threadId, role);
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
|
||||
test("empty session ID is treated as missing", async () => {
|
||||
await setCachedSessionId("claude-code", threadId, role, "");
|
||||
|
||||
const session = await getCachedSessionId("claude-code", threadId, role);
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("file system operations", () => {
|
||||
const threadId = "01234567890123456789012345" as ThreadId;
|
||||
const role = "developer";
|
||||
|
||||
test("cache directory is created if missing", async () => {
|
||||
const cachePath = getCachePath("claude-code");
|
||||
const cacheDir = dirname(cachePath);
|
||||
|
||||
// Ensure cache dir doesn't exist
|
||||
await rm(cacheDir, { recursive: true, force: true });
|
||||
|
||||
// Write a session
|
||||
await setCachedSessionId("claude-code", threadId, role, "session-001");
|
||||
|
||||
// Cache directory should be created
|
||||
const stats = await stat(cacheDir);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
test("multiple agents create separate cache files", async () => {
|
||||
// Cache sessions for multiple agents
|
||||
await setCachedSessionId("claude-code", threadId, role, "session-cc-001");
|
||||
await setCachedSessionId("hermes", threadId, role, "session-hermes-001");
|
||||
|
||||
// Separate cache files should exist
|
||||
const pathCC = getCachePath("claude-code");
|
||||
const pathHermes = getCachePath("hermes");
|
||||
|
||||
const contentCC = JSON.parse(await readFile(pathCC, "utf8")) as Record<string, string>;
|
||||
const contentHermes = JSON.parse(await readFile(pathHermes, "utf8")) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
|
||||
expect(contentCC).toHaveProperty(`${threadId}:${role}`, "session-cc-001");
|
||||
expect(contentHermes).toHaveProperty(`${threadId}:${role}`, "session-hermes-001");
|
||||
});
|
||||
|
||||
test("atomic writes prevent partial reads", async () => {
|
||||
// Write a session
|
||||
await setCachedSessionId("claude-code", threadId, role, "session-001");
|
||||
|
||||
// The final file should exist (no .tmp files left behind)
|
||||
const cachePath = getCachePath("claude-code");
|
||||
const dir = dirname(cachePath);
|
||||
const files = await readdir(dir);
|
||||
|
||||
expect(files).toContain("claude-code-sessions.json");
|
||||
expect(files.every((f) => !f.endsWith(".tmp"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy migration", () => {
|
||||
const threadId = "01234567890123456789012345" as ThreadId;
|
||||
const role = "developer";
|
||||
|
||||
test("old agent-sessions.json is ignored", async () => {
|
||||
// Create old agent-sessions.json file
|
||||
const oldCachePath = join(resolveStorageRoot(), "cache", "agent-sessions.json");
|
||||
await mkdir(dirname(oldCachePath), { recursive: true });
|
||||
await writeFile(
|
||||
oldCachePath,
|
||||
JSON.stringify({
|
||||
"01234567890123456789012345:developer": "old-session-001",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// Query with the new per-agent cache
|
||||
const session = await getCachedSessionId("claude-code", threadId, role);
|
||||
|
||||
// Should return null (old cache is ignored)
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
|
||||
test("new per-agent cache takes precedence", async () => {
|
||||
// Create both old and new cache files
|
||||
const oldPath = join(resolveStorageRoot(), "cache", "agent-sessions.json");
|
||||
await mkdir(dirname(oldPath), { recursive: true });
|
||||
await writeFile(
|
||||
oldPath,
|
||||
JSON.stringify({
|
||||
[`${threadId}:${role}`]: "old-session",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await setCachedSessionId("claude-code", threadId, role, "new-session");
|
||||
|
||||
// The new per-agent cache value should be returned
|
||||
const session = await getCachedSessionId("claude-code", threadId, role);
|
||||
expect(session).toBe("new-session");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
const threadId = "01234567890123456789012345" as ThreadId;
|
||||
const role = "developer";
|
||||
|
||||
test("invalid JSON in cache file returns empty cache", async () => {
|
||||
// Create a corrupted cache file
|
||||
const cachePath = getCachePath("claude-code");
|
||||
await mkdir(dirname(cachePath), { recursive: true });
|
||||
await writeFile(cachePath, "{ invalid json }", "utf8");
|
||||
|
||||
// Should return null (treating corrupted cache as empty)
|
||||
const session = await getCachedSessionId("claude-code", threadId, role);
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
|
||||
test("non-object JSON in cache file returns empty cache", async () => {
|
||||
// Create a cache file with non-object JSON
|
||||
const cachePath = getCachePath("claude-code");
|
||||
await mkdir(dirname(cachePath), { recursive: true });
|
||||
await writeFile(cachePath, JSON.stringify(["not", "an", "object"]), "utf8");
|
||||
|
||||
// Should return null
|
||||
const session = await getCachedSessionId("claude-code", threadId, role);
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
|
||||
test("cache entries with non-string values are ignored", async () => {
|
||||
// Create a cache file with mixed types
|
||||
const cachePath = getCachePath("claude-code");
|
||||
const cacheData = {
|
||||
"thread1:role1": "valid-session",
|
||||
"thread2:role2": 12345, // number
|
||||
"thread3:role3": null, // null
|
||||
"thread4:role4": "", // empty string
|
||||
};
|
||||
await mkdir(dirname(cachePath), { recursive: true });
|
||||
await writeFile(cachePath, JSON.stringify(cacheData), "utf8");
|
||||
|
||||
// Valid string entries should be returned
|
||||
const session1 = await getCachedSessionId("claude-code", "thread1" as ThreadId, "role1");
|
||||
expect(session1).toBe("valid-session");
|
||||
|
||||
// Invalid entries should return null
|
||||
const session2 = await getCachedSessionId("claude-code", "thread2" as ThreadId, "role2");
|
||||
const session3 = await getCachedSessionId("claude-code", "thread3" as ThreadId, "role3");
|
||||
const session4 = await getCachedSessionId("claude-code", "thread4" as ThreadId, "role4");
|
||||
|
||||
expect(session2).toBeNull();
|
||||
expect(session3).toBeNull();
|
||||
expect(session4).toBeNull(); // empty string is treated as missing
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@united-workforce/util-agent",
|
||||
"version": "0.5.0",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
|
||||
"test": "bun test __tests__/ src/__tests__/",
|
||||
"test:ci": "bun test __tests__/ src/__tests__/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ocas/core": "^0.1.1",
|
||||
"@ocas/fs": "^0.1.1",
|
||||
"@united-workforce/protocol": "workspace:^",
|
||||
"@united-workforce/util": "workspace:^",
|
||||
"dotenv": "^16.6.1",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.shazhou.work/uncaged/workflow.git",
|
||||
"directory": "packages/util-agent"
|
||||
},
|
||||
"homepage": "https://git.shazhou.work/uncaged/workflow#readme",
|
||||
"bugs": {
|
||||
"url": "https://git.shazhou.work/uncaged/workflow/issues"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
|
||||
describe("parseArgv empty prompt error message", () => {
|
||||
let stderrOutput: string;
|
||||
let _exitCode: number | null;
|
||||
const originalExit = process.exit;
|
||||
const originalStderrWrite = process.stderr.write;
|
||||
|
||||
beforeEach(() => {
|
||||
stderrOutput = "";
|
||||
_exitCode = null;
|
||||
process.exit = ((code?: number) => {
|
||||
_exitCode = code ?? 1;
|
||||
throw new Error("process.exit called");
|
||||
}) as any;
|
||||
process.stderr.write = ((chunk: string) => {
|
||||
stderrOutput += chunk;
|
||||
return true;
|
||||
}) as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.exit = originalExit;
|
||||
process.stderr.write = originalStderrWrite;
|
||||
});
|
||||
|
||||
test("empty prompt produces error message mentioning template variables", async () => {
|
||||
const { parseArgv } = await import("../run.js");
|
||||
const argv = [
|
||||
"node",
|
||||
"uwf-hermes",
|
||||
"--thread",
|
||||
"01ABCDEFGHIJKLMNOPQRSTUVWX",
|
||||
"--role",
|
||||
"classifier",
|
||||
"--prompt",
|
||||
"",
|
||||
];
|
||||
|
||||
expect(() => parseArgv(argv)).toThrow("process.exit called");
|
||||
expect(stderrOutput).toContain("prompt");
|
||||
expect(stderrOutput).toContain("empty");
|
||||
expect(stderrOutput).toContain("template");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { StepContext } from "@united-workforce/protocol";
|
||||
|
||||
function formatStep(step: StepContext, stepNumber: number, includeContent: boolean): string {
|
||||
const lines = [
|
||||
`### Step ${stepNumber}: ${step.role}`,
|
||||
`Output: ${JSON.stringify(step.output)}`,
|
||||
`Agent: ${step.agent}`,
|
||||
];
|
||||
|
||||
if (includeContent && step.content !== null) {
|
||||
lines.push("");
|
||||
lines.push("#### Step Content");
|
||||
lines.push("");
|
||||
lines.push(step.content);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function findLastRoleIndex(steps: StepContext[], role: string): number {
|
||||
for (let i = steps.length - 1; i >= 0; i--) {
|
||||
const step = steps[i];
|
||||
if (step !== undefined && step.role === role) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function selectStepsWithinQuota(steps: StepContext[], quota: number): StepContext[] {
|
||||
const selected: StepContext[] = [];
|
||||
let totalChars = 0;
|
||||
|
||||
// Work backwards (newest first)
|
||||
for (let i = steps.length - 1; i >= 0; i--) {
|
||||
const step = steps[i];
|
||||
if (step === undefined) continue;
|
||||
|
||||
// Estimate size: meta + content
|
||||
const metaSize = JSON.stringify({
|
||||
role: step.role,
|
||||
output: step.output,
|
||||
agent: step.agent,
|
||||
}).length;
|
||||
const contentSize = step.content?.length ?? 0;
|
||||
const stepSize = metaSize + contentSize;
|
||||
|
||||
if (totalChars + stepSize > quota && selected.length > 0) {
|
||||
// Stop adding steps but keep at least 1
|
||||
break;
|
||||
}
|
||||
|
||||
selected.unshift(step); // Keep chronological order
|
||||
totalChars += stepSize;
|
||||
|
||||
if (totalChars >= quota) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
type BuildContinuationPromptOptions = {
|
||||
includeContent?: boolean;
|
||||
quota?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a continuation prompt for a role re-entry.
|
||||
*
|
||||
* Finds the most recent step for `role`, collects everything after it as context,
|
||||
* and appends the moderator edge prompt as the instruction.
|
||||
*/
|
||||
export function buildContinuationPrompt(
|
||||
steps: StepContext[],
|
||||
role: string,
|
||||
edgePrompt: string,
|
||||
options?: BuildContinuationPromptOptions,
|
||||
): string {
|
||||
const includeContent = options?.includeContent ?? false;
|
||||
const quota = options?.quota ?? Number.POSITIVE_INFINITY;
|
||||
|
||||
const lastIndex = findLastRoleIndex(steps, role);
|
||||
const sinceSteps = lastIndex >= 0 ? steps.slice(lastIndex + 1) : steps;
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (sinceSteps.length > 0) {
|
||||
parts.push("## What Happened Since Your Last Turn");
|
||||
const baseStepNumber = lastIndex >= 0 ? lastIndex + 2 : 1;
|
||||
|
||||
// Select steps within quota (newest-first if includeContent = true)
|
||||
const selectedSteps = includeContent ? selectStepsWithinQuota(sinceSteps, quota) : sinceSteps;
|
||||
|
||||
const skippedCount = sinceSteps.length - selectedSteps.length;
|
||||
if (skippedCount > 0) {
|
||||
parts.push("");
|
||||
parts.push(
|
||||
`_Showing ${selectedSteps.length} of ${sinceSteps.length} steps (${skippedCount} omitted due to quota)_`,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < selectedSteps.length; i++) {
|
||||
const step = selectedSteps[i];
|
||||
if (step === undefined) {
|
||||
continue;
|
||||
}
|
||||
parts.push("");
|
||||
parts.push(formatStep(step, baseStepNumber + i, includeContent));
|
||||
}
|
||||
parts.push("");
|
||||
}
|
||||
|
||||
parts.push("## Moderator Instruction", "", edgePrompt);
|
||||
return parts.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import type { JSONSchema } from "@ocas/core";
|
||||
|
||||
type SchemaProperty = {
|
||||
name: string;
|
||||
schema: JSONSchema;
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract top-level property names from a JSON Schema object.
|
||||
*
|
||||
* Handles:
|
||||
* - Object schemas with a `properties` key
|
||||
* - Union schemas via `anyOf` / `oneOf` — union of all variant property names
|
||||
*
|
||||
* Returns an empty array for schemas with no inspectable property definitions.
|
||||
*/
|
||||
export function extractSchemaFields(schema: JSONSchema): string[] {
|
||||
return extractSchemaProperties(schema).map((p) => p.name);
|
||||
}
|
||||
|
||||
function extractSchemaProperties(schema: JSONSchema): SchemaProperty[] {
|
||||
const objectSchemas = collectObjectSchemas(schema);
|
||||
if (objectSchemas.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const byName = new Map<string, SchemaProperty>();
|
||||
|
||||
for (const objectSchema of objectSchemas) {
|
||||
const requiredSet = new Set(
|
||||
Array.isArray(objectSchema.required) ? (objectSchema.required as string[]) : [],
|
||||
);
|
||||
const properties = objectSchema.properties as Record<string, JSONSchema> | null | undefined;
|
||||
if (typeof properties !== "object" || properties === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [name, propSchema] of Object.entries(properties)) {
|
||||
const required = requiredSet.has(name);
|
||||
const existing = byName.get(name);
|
||||
if (existing === undefined) {
|
||||
byName.set(name, { name, schema: propSchema, required });
|
||||
} else if (required) {
|
||||
byName.set(name, { ...existing, required: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...byName.values()];
|
||||
}
|
||||
|
||||
function collectObjectSchemas(schema: JSONSchema): JSONSchema[] {
|
||||
if (typeof schema.properties === "object" && schema.properties !== null) {
|
||||
return [schema];
|
||||
}
|
||||
|
||||
const unionKey = Array.isArray(schema.anyOf)
|
||||
? "anyOf"
|
||||
: Array.isArray(schema.oneOf)
|
||||
? "oneOf"
|
||||
: null;
|
||||
|
||||
if (unionKey === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const variants = schema[unionKey] as JSONSchema[];
|
||||
const result: JSONSchema[] = [];
|
||||
for (const variant of variants) {
|
||||
result.push(...collectObjectSchemas(variant));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolvePropertySchema(prop: JSONSchema): JSONSchema {
|
||||
if (Array.isArray(prop.enum) && prop.enum.length > 0) {
|
||||
return prop;
|
||||
}
|
||||
|
||||
const unionKey = Array.isArray(prop.anyOf) ? "anyOf" : Array.isArray(prop.oneOf) ? "oneOf" : null;
|
||||
|
||||
if (unionKey !== null) {
|
||||
const variants = prop[unionKey] as JSONSchema[];
|
||||
const nonNull = variants.filter((v) => v.type !== "null");
|
||||
if (nonNull.length === 1) {
|
||||
return nonNull[0];
|
||||
}
|
||||
}
|
||||
|
||||
return prop;
|
||||
}
|
||||
|
||||
function formatYamlScalar(value: unknown): string {
|
||||
if (typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return String(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function buildPropertyComment(parts: string[]): string {
|
||||
const filtered = parts.filter((p) => p.length > 0);
|
||||
return filtered.length > 0 ? ` # ${filtered.join(" | ")}` : "";
|
||||
}
|
||||
|
||||
function buildPropertyExampleLine(prop: SchemaProperty): string {
|
||||
const resolved = resolvePropertySchema(prop.schema);
|
||||
const commentParts: string[] = [];
|
||||
if (prop.required) {
|
||||
commentParts.push("required");
|
||||
}
|
||||
|
||||
if (Array.isArray(resolved.enum) && resolved.enum.length > 0) {
|
||||
const enumValues = resolved.enum.map((v) => String(v));
|
||||
commentParts.push(...enumValues);
|
||||
const first = resolved.enum[0];
|
||||
return `${prop.name}: ${formatYamlScalar(first)}${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
if (resolved.type === "boolean") {
|
||||
commentParts.push("true", "false");
|
||||
return `${prop.name}: true${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
if (resolved.type === "string") {
|
||||
return `${prop.name}: <string>${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
if (resolved.type === "number" || resolved.type === "integer") {
|
||||
return `${prop.name}: <number>${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
if (resolved.type === "array") {
|
||||
return `${prop.name}:\n - <item>${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
if (resolved.type === "object") {
|
||||
return `${prop.name}: <object>${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
return `${prop.name}: <value>${buildPropertyComment(commentParts)}`;
|
||||
}
|
||||
|
||||
function buildYamlExampleBlock(properties: SchemaProperty[]): string {
|
||||
if (properties.length === 0) {
|
||||
return "---\n\n... your markdown work here ...";
|
||||
}
|
||||
|
||||
const lines = properties.map((p) => buildPropertyExampleLine(p));
|
||||
return `---\n${lines.join("\n")}\n---\n\n... your markdown work here ...`;
|
||||
}
|
||||
|
||||
function buildFieldList(properties: SchemaProperty[]): string {
|
||||
if (properties.length === 0) {
|
||||
return " (schema fields will be extracted automatically)";
|
||||
}
|
||||
|
||||
return properties
|
||||
.map((p) => {
|
||||
const suffix = p.required ? " (required)" : "";
|
||||
return ` - \`${p.name}\`${suffix}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the discriminant property name from a oneOf schema.
|
||||
* Returns the property name if all variants share a const/single-enum string property, else null.
|
||||
*/
|
||||
function detectDiscriminant(variants: JSONSchema[]): string | null {
|
||||
// Find property names that appear in ALL variants with const or single-enum
|
||||
const candidateNames = new Set<string>();
|
||||
|
||||
for (const variant of variants) {
|
||||
const props = variant.properties as Record<string, JSONSchema> | null | undefined;
|
||||
if (typeof props !== "object" || props === null) return null;
|
||||
|
||||
for (const [name, propSchema] of Object.entries(props)) {
|
||||
const isConst =
|
||||
propSchema.const !== undefined ||
|
||||
(Array.isArray(propSchema.enum) && propSchema.enum.length === 1);
|
||||
if (isConst) candidateNames.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Check which candidate appears in ALL variants
|
||||
for (const name of candidateNames) {
|
||||
const allHaveIt = variants.every((v) => {
|
||||
const props = v.properties as Record<string, JSONSchema> | null | undefined;
|
||||
if (typeof props !== "object" || props === null) return false;
|
||||
const propSchema = props[name];
|
||||
if (!propSchema) return false;
|
||||
return (
|
||||
propSchema.const !== undefined ||
|
||||
(Array.isArray(propSchema.enum) && propSchema.enum.length === 1)
|
||||
);
|
||||
});
|
||||
if (allHaveIt) return name;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getConstValue(propSchema: JSONSchema): string {
|
||||
if (propSchema.const !== undefined) return String(propSchema.const);
|
||||
if (Array.isArray(propSchema.enum) && propSchema.enum.length === 1)
|
||||
return String(propSchema.enum[0]);
|
||||
return "<unknown>";
|
||||
}
|
||||
|
||||
function buildVariantBlock(variant: JSONSchema, discriminant: string): string {
|
||||
const props = extractSchemaProperties(variant);
|
||||
const value = getConstValue(
|
||||
(variant.properties as Record<string, JSONSchema>)?.[discriminant] ?? {},
|
||||
);
|
||||
const yamlExample = buildYamlExampleBlock(props);
|
||||
const fieldList = buildFieldList(props);
|
||||
|
||||
return `### When \`${discriminant}: ${value}\`
|
||||
|
||||
\`\`\`
|
||||
${yamlExample}
|
||||
\`\`\`
|
||||
|
||||
Fields:
|
||||
${fieldList}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a concise output format instruction block for an agent role.
|
||||
*
|
||||
* For discriminated union schemas (oneOf with a shared const/$status field),
|
||||
* renders per-variant instructions so the agent knows exactly which fields
|
||||
* belong to which outcome.
|
||||
*
|
||||
* For flat object schemas, renders a single YAML example block.
|
||||
*/
|
||||
export function buildOutputFormatInstruction(schema: JSONSchema): string {
|
||||
// Check for discriminated union (oneOf with shared discriminant)
|
||||
const unionKey = Array.isArray(schema.oneOf)
|
||||
? "oneOf"
|
||||
: Array.isArray(schema.anyOf)
|
||||
? "anyOf"
|
||||
: null;
|
||||
|
||||
if (unionKey !== null) {
|
||||
const variants = schema[unionKey] as JSONSchema[];
|
||||
const discriminant = detectDiscriminant(variants);
|
||||
|
||||
if (discriminant !== null && variants.length > 1) {
|
||||
const variantBlocks = variants.map((v) => buildVariantBlock(v, discriminant)).join("\n\n");
|
||||
|
||||
return `## Deliverable Format
|
||||
|
||||
Your response MUST begin with a YAML frontmatter block followed by your markdown work.
|
||||
|
||||
Choose ONE of the following variants based on your outcome:
|
||||
|
||||
${variantBlocks}
|
||||
|
||||
The frontmatter is the **primary deliverable** — the engine reads it directly.
|
||||
Output ONLY the fields listed for your chosen variant. Do not add extra fields that are not specified in the schema.
|
||||
|
||||
Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Flat object schema fallback
|
||||
const properties = extractSchemaProperties(schema);
|
||||
const yamlExample = buildYamlExampleBlock(properties);
|
||||
const fieldList = buildFieldList(properties);
|
||||
|
||||
return `## Deliverable Format
|
||||
|
||||
Your response MUST begin with a YAML frontmatter block followed by your markdown work:
|
||||
|
||||
\`\`\`
|
||||
${yamlExample}
|
||||
\`\`\`
|
||||
|
||||
The frontmatter is the **primary deliverable** — the engine reads it directly.
|
||||
Your meta output must satisfy these fields:
|
||||
|
||||
${fieldList}
|
||||
|
||||
Output ONLY the fields listed above. Do not add extra fields that are not specified in the schema.
|
||||
|
||||
Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope.`;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { RoleDefinition } from "@united-workforce/protocol";
|
||||
import { generateCliReference } from "@united-workforce/util";
|
||||
|
||||
/**
|
||||
* Build the role prompt from a RoleDefinition.
|
||||
*
|
||||
* Assembles structured sections: Goal, Capabilities, Prepare, Procedure, Output.
|
||||
* Empty strings and empty arrays are omitted from the output.
|
||||
*
|
||||
* The Prepare section always inlines the uwf CLI reference so the agent has
|
||||
* workflow knowledge without needing to run an external command. The capabilities
|
||||
* array is rendered as keyword hints for implicit skill loading.
|
||||
*/
|
||||
export function buildRolePrompt(role: RoleDefinition): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
if (role.goal !== "") {
|
||||
sections.push(`## Goal\n\n${role.goal}`);
|
||||
}
|
||||
|
||||
if (role.capabilities.length > 0) {
|
||||
const list = role.capabilities.map((c) => `- ${c}`).join("\n");
|
||||
sections.push(`## Capabilities\n\n${list}`);
|
||||
}
|
||||
|
||||
const prepareLines: string[] = [generateCliReference()];
|
||||
if (role.capabilities.length > 0) {
|
||||
const keywords = role.capabilities.join(", ");
|
||||
prepareLines.push(
|
||||
`You have the following capabilities: ${keywords}. Load relevant skills matching these keywords before starting work.`,
|
||||
);
|
||||
}
|
||||
sections.push(`## Prepare\n\n${prepareLines.join("\n\n")}`);
|
||||
|
||||
if (role.procedure !== "") {
|
||||
sections.push(`## Procedure\n\n${role.procedure}`);
|
||||
}
|
||||
|
||||
if (role.output !== "") {
|
||||
sections.push(`## Output\n\n${role.output}`);
|
||||
}
|
||||
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { Store } from "@ocas/core";
|
||||
import type {
|
||||
CasRef,
|
||||
StartNodePayload,
|
||||
StepContext,
|
||||
StepNodePayload,
|
||||
ThreadId,
|
||||
} from "@united-workforce/protocol";
|
||||
import type { AgentStore } from "./storage.js";
|
||||
import { createAgentStore, getActiveThreadEntry, resolveStorageRoot } from "./storage.js";
|
||||
import type { AgentContext } from "./types.js";
|
||||
|
||||
type ChainState = {
|
||||
startHash: CasRef;
|
||||
start: StartNodePayload;
|
||||
stepsNewestFirst: StepNodePayload[];
|
||||
headIsStart: boolean;
|
||||
};
|
||||
|
||||
function fail(message: string): never {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function walkChain(store: Store, schemas: AgentStore["schemas"], headHash: CasRef): ChainState {
|
||||
const headNode = store.get(headHash);
|
||||
if (headNode === null) {
|
||||
fail(`CAS node not found: ${headHash}`);
|
||||
}
|
||||
|
||||
if (headNode.type === schemas.startNode) {
|
||||
return {
|
||||
startHash: headHash,
|
||||
start: headNode.payload as StartNodePayload,
|
||||
stepsNewestFirst: [],
|
||||
headIsStart: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (headNode.type !== schemas.stepNode) {
|
||||
fail(`head ${headHash} is not a StartNode or StepNode`);
|
||||
}
|
||||
|
||||
const stepsNewestFirst: StepNodePayload[] = [];
|
||||
let hash: CasRef | null = headHash;
|
||||
|
||||
while (hash !== null) {
|
||||
const node = store.get(hash);
|
||||
if (node === null) {
|
||||
fail(`CAS node not found while walking chain: ${hash}`);
|
||||
}
|
||||
if (node.type !== schemas.stepNode) {
|
||||
break;
|
||||
}
|
||||
const payload = node.payload as StepNodePayload;
|
||||
stepsNewestFirst.push(payload);
|
||||
hash = payload.prev;
|
||||
}
|
||||
|
||||
const newest = stepsNewestFirst[0];
|
||||
if (newest === undefined) {
|
||||
fail(`empty step chain at head ${headHash}`);
|
||||
}
|
||||
|
||||
const startNode = store.get(newest.start);
|
||||
if (startNode === null || startNode.type !== schemas.startNode) {
|
||||
fail(`StartNode not found: ${newest.start}`);
|
||||
}
|
||||
|
||||
return {
|
||||
startHash: newest.start,
|
||||
start: startNode.payload as StartNodePayload,
|
||||
stepsNewestFirst,
|
||||
headIsStart: false,
|
||||
};
|
||||
}
|
||||
|
||||
function expandOutput(store: Store, outputRef: CasRef): unknown {
|
||||
const node = store.get(outputRef);
|
||||
if (node === null) {
|
||||
return {};
|
||||
}
|
||||
return node.payload;
|
||||
}
|
||||
|
||||
function extractStepContent(store: Store, detailRef: CasRef): string | null {
|
||||
const detailNode = store.get(detailRef);
|
||||
if (detailNode === null) {
|
||||
return null;
|
||||
}
|
||||
const detail = detailNode.payload as Record<string, unknown>;
|
||||
const turns = detail.turns;
|
||||
if (!Array.isArray(turns) || turns.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// Find last assistant content (same logic as extractLastAssistantContent in cli)
|
||||
for (let i = turns.length - 1; i >= 0; i--) {
|
||||
const turnRef = turns[i];
|
||||
if (typeof turnRef !== "string") {
|
||||
continue;
|
||||
}
|
||||
const turnNode = store.get(turnRef as CasRef);
|
||||
if (turnNode === null) {
|
||||
continue;
|
||||
}
|
||||
const turn = turnNode.payload as Record<string, unknown>;
|
||||
if (
|
||||
turn.role === "assistant" &&
|
||||
typeof turn.content === "string" &&
|
||||
turn.content.trim() !== ""
|
||||
) {
|
||||
return turn.content;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function buildHistory(
|
||||
store: Store,
|
||||
stepsNewestFirst: StepNodePayload[],
|
||||
): Promise<StepContext[]> {
|
||||
const chronological = [...stepsNewestFirst].reverse();
|
||||
const history: StepContext[] = [];
|
||||
for (const step of chronological) {
|
||||
const content = extractStepContent(store, step.detail);
|
||||
history.push({
|
||||
role: step.role,
|
||||
output: expandOutput(store, step.output),
|
||||
detail: step.detail,
|
||||
agent: step.agent,
|
||||
edgePrompt: step.edgePrompt ?? "",
|
||||
startedAtMs: step.startedAtMs,
|
||||
completedAtMs: step.completedAtMs,
|
||||
cwd: step.cwd ?? "",
|
||||
assembledPrompt: step.assembledPrompt ?? null,
|
||||
content,
|
||||
});
|
||||
}
|
||||
return history;
|
||||
}
|
||||
|
||||
async function loadWorkflow(store: Store, schemas: AgentStore["schemas"], workflowRef: CasRef) {
|
||||
const node = store.get(workflowRef);
|
||||
if (node === null) {
|
||||
fail(`workflow CAS node not found: ${workflowRef}`);
|
||||
}
|
||||
if (node.type !== schemas.workflow) {
|
||||
fail(`node ${workflowRef} is not a Workflow`);
|
||||
}
|
||||
return node.payload as AgentContext["workflow"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build agent execution context from thread head in threads.yaml.
|
||||
* Walks the CAS chain from head to StartNode and expands step outputs.
|
||||
*/
|
||||
export async function buildContext(
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
edgePrompt: string,
|
||||
): Promise<AgentContext> {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
const agentStore = await createAgentStore(storageRoot);
|
||||
const { store, schemas } = agentStore;
|
||||
|
||||
const entry = await getActiveThreadEntry(storageRoot, threadId);
|
||||
if (entry === null) {
|
||||
fail(`thread not found in active thread index: ${threadId}`);
|
||||
}
|
||||
|
||||
const chain = walkChain(store, schemas, entry.head);
|
||||
const workflow = await loadWorkflow(store, schemas, chain.start.workflow);
|
||||
const roleDef = workflow.roles[role];
|
||||
if (roleDef === undefined) {
|
||||
fail(`unknown role "${role}" in workflow "${workflow.name}"`);
|
||||
}
|
||||
|
||||
const steps = await buildHistory(store, chain.stepsNewestFirst);
|
||||
const isFirstVisit = !steps.some((s) => s.role === role);
|
||||
|
||||
return {
|
||||
threadId,
|
||||
role,
|
||||
start: chain.start,
|
||||
steps,
|
||||
workflow,
|
||||
store,
|
||||
outputFormatInstruction: "",
|
||||
edgePrompt,
|
||||
isFirstVisit,
|
||||
};
|
||||
}
|
||||
|
||||
export type BuildContextMeta = {
|
||||
storageRoot: string;
|
||||
store: Store;
|
||||
schemas: AgentStore["schemas"];
|
||||
headHash: CasRef;
|
||||
chain: ChainState;
|
||||
};
|
||||
|
||||
/**
|
||||
* Same as {@link buildContext} but also returns chain metadata for writing the next StepNode.
|
||||
*/
|
||||
export async function buildContextWithMeta(
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
edgePrompt: string,
|
||||
): Promise<AgentContext & { meta: BuildContextMeta }> {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
const agentStore = await createAgentStore(storageRoot);
|
||||
const { store, schemas } = agentStore;
|
||||
|
||||
const entry = await getActiveThreadEntry(storageRoot, threadId);
|
||||
if (entry === null) {
|
||||
fail(`thread not found in active thread index: ${threadId}`);
|
||||
}
|
||||
|
||||
const chain = walkChain(store, schemas, entry.head);
|
||||
const workflow = await loadWorkflow(store, schemas, chain.start.workflow);
|
||||
const roleDef = workflow.roles[role];
|
||||
if (roleDef === undefined) {
|
||||
fail(`unknown role "${role}" in workflow "${workflow.name}"`);
|
||||
}
|
||||
|
||||
const steps = await buildHistory(store, chain.stepsNewestFirst);
|
||||
const isFirstVisit = !steps.some((s) => s.role === role);
|
||||
|
||||
return {
|
||||
threadId,
|
||||
role,
|
||||
start: chain.start,
|
||||
steps,
|
||||
workflow,
|
||||
store,
|
||||
outputFormatInstruction: "",
|
||||
edgePrompt,
|
||||
isFirstVisit,
|
||||
meta: { storageRoot, store, schemas, headHash: entry.head, chain },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { getSchema, validate } from "@ocas/core";
|
||||
|
||||
import type { CasRef, ModelAlias, WorkflowConfig } from "@united-workforce/protocol";
|
||||
import { createAgentStore, resolveStorageRoot } from "./storage.js";
|
||||
|
||||
export type ResolvedLlmProvider = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Resolve model alias for extract: modelOverrides.extract → models.extract → defaultModel. */
|
||||
export function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias {
|
||||
const fromOverride = config.modelOverrides?.extract ?? null;
|
||||
if (fromOverride !== null) {
|
||||
return fromOverride;
|
||||
}
|
||||
if (config.models.extract !== undefined) {
|
||||
return "extract";
|
||||
}
|
||||
if (config.models.default !== undefined) {
|
||||
return "default";
|
||||
}
|
||||
return config.defaultModel;
|
||||
}
|
||||
|
||||
export function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider {
|
||||
const modelEntry = config.models[alias];
|
||||
if (modelEntry === undefined) {
|
||||
throw new Error(`unknown model alias: ${alias}`);
|
||||
}
|
||||
const providerEntry = config.providers[modelEntry.provider];
|
||||
if (providerEntry === undefined) {
|
||||
throw new Error(`unknown provider "${modelEntry.provider}" for model "${alias}"`);
|
||||
}
|
||||
const apiKey = providerEntry.apiKey;
|
||||
if (apiKey === undefined || apiKey === "") {
|
||||
throw new Error(`missing API key for provider: ${modelEntry.provider}`);
|
||||
}
|
||||
return {
|
||||
baseUrl: providerEntry.baseUrl,
|
||||
apiKey,
|
||||
model: modelEntry.name,
|
||||
};
|
||||
}
|
||||
|
||||
function chatUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.replace(/\/+$/, "");
|
||||
return `${trimmed}/chat/completions`;
|
||||
}
|
||||
|
||||
function extractJsonFromAssistantText(text: string): unknown {
|
||||
const trimmed = text.trim();
|
||||
const fenceMatch = /^```(?:json)?\s*([\s\S]*?)```$/m.exec(trimmed);
|
||||
const candidate = fenceMatch !== null ? fenceMatch[1].trim() : trimmed;
|
||||
return JSON.parse(candidate) as unknown;
|
||||
}
|
||||
|
||||
function parseAssistantText(parsed: unknown): string {
|
||||
if (!isRecord(parsed)) {
|
||||
throw new Error("LLM response is not an object");
|
||||
}
|
||||
const choices = parsed.choices;
|
||||
if (!Array.isArray(choices) || choices.length === 0) {
|
||||
throw new Error("LLM response has no choices");
|
||||
}
|
||||
const c0 = choices[0];
|
||||
if (!isRecord(c0)) {
|
||||
throw new Error("LLM choice is not an object");
|
||||
}
|
||||
const messageObj = c0.message;
|
||||
if (!isRecord(messageObj)) {
|
||||
throw new Error("LLM message is not an object");
|
||||
}
|
||||
const content = messageObj.content;
|
||||
if (typeof content !== "string") {
|
||||
throw new Error("LLM message has no text content");
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
async function chatCompletionText(
|
||||
provider: ResolvedLlmProvider,
|
||||
messages: Array<{ role: "system" | "user"; content: string }>,
|
||||
): Promise<string> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(chatUrl(provider.baseUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: provider.model,
|
||||
messages,
|
||||
response_format: { type: "json_object" },
|
||||
}),
|
||||
});
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
throw new Error(`LLM network error: ${message}`);
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`LLM HTTP ${response.status}: ${responseText.slice(0, 2000)}`);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(responseText) as unknown;
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
throw new Error(`LLM invalid JSON response: ${message}`);
|
||||
}
|
||||
|
||||
return parseAssistantText(parsed);
|
||||
}
|
||||
|
||||
export type ExtractResult = {
|
||||
value: unknown;
|
||||
hash: CasRef;
|
||||
};
|
||||
|
||||
/**
|
||||
* Call an OpenAI-compatible LLM to extract structured output matching outputSchema.
|
||||
* Loads config.yaml from the workflow storage root.
|
||||
*/
|
||||
export async function extract(
|
||||
rawOutput: string,
|
||||
outputSchema: CasRef,
|
||||
config: WorkflowConfig,
|
||||
): Promise<ExtractResult> {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
|
||||
const { store } = await createAgentStore(storageRoot);
|
||||
const schema = getSchema(store, outputSchema);
|
||||
if (schema === null) {
|
||||
throw new Error(`output schema not found in CAS: ${outputSchema}`);
|
||||
}
|
||||
|
||||
const modelAlias = resolveExtractModelAlias(config);
|
||||
const provider = resolveModel(config, modelAlias);
|
||||
|
||||
const schemaText = JSON.stringify(schema, null, 2);
|
||||
const assistantText = await chatCompletionText(provider, [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
"Extract structured data from the agent output. Reply with a single JSON object only, no markdown or prose. The JSON must validate against this JSON Schema:\n" +
|
||||
schemaText,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: rawOutput,
|
||||
},
|
||||
]);
|
||||
|
||||
let structured: unknown;
|
||||
try {
|
||||
structured = extractJsonFromAssistantText(assistantText);
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
throw new Error(`failed to parse extracted JSON: ${message}`);
|
||||
}
|
||||
|
||||
const outputHash = await store.put(outputSchema, structured);
|
||||
const node = store.get(outputHash);
|
||||
if (node === null || !validate(store, node)) {
|
||||
throw new Error("extracted output failed JSON Schema validation");
|
||||
}
|
||||
|
||||
return { value: structured, hash: outputHash };
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { Store } from "@ocas/core";
|
||||
import { getSchema, validate } from "@ocas/core";
|
||||
import type { CasRef } from "@united-workforce/protocol";
|
||||
import {
|
||||
type AgentFrontmatter,
|
||||
createLogger,
|
||||
parseFrontmatterMarkdown,
|
||||
validateFrontmatter,
|
||||
} from "@united-workforce/util";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
import { extractSchemaFields } from "./build-output-format-instruction.js";
|
||||
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
const STANDARD_KEYS = ["status"] as const;
|
||||
|
||||
type StandardKey = (typeof STANDARD_KEYS)[number];
|
||||
|
||||
export type FrontmatterFastPathResult = {
|
||||
body: string;
|
||||
outputHash: CasRef;
|
||||
frontmatter: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function extractYamlBlock(raw: string): string | null {
|
||||
const fence = "---";
|
||||
if (!raw.startsWith(fence)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rest = raw.slice(fence.length);
|
||||
if (rest.length > 0 && rest[0] !== "\n" && rest[0] !== "\r") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const afterOpen = rest.startsWith("\n") ? rest.slice(1) : rest;
|
||||
const closeIndex = afterOpen.indexOf(`\n${fence}`);
|
||||
if (closeIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return afterOpen.slice(0, closeIndex);
|
||||
}
|
||||
|
||||
function parseRawFrontmatterFields(raw: string): Record<string, unknown> {
|
||||
const yamlText = extractYamlBlock(raw);
|
||||
if (yamlText === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseYaml(yamlText);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function defaultCandidate(frontmatter: AgentFrontmatter): Record<string, unknown> {
|
||||
return {
|
||||
status: frontmatter.status,
|
||||
};
|
||||
}
|
||||
|
||||
function pickStandardField(frontmatter: AgentFrontmatter, key: StandardKey): unknown {
|
||||
switch (key) {
|
||||
case "status":
|
||||
return frontmatter.status;
|
||||
}
|
||||
}
|
||||
|
||||
function isStandardKey(key: string): key is StandardKey {
|
||||
return (STANDARD_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
function pickFieldValue(
|
||||
field: string,
|
||||
frontmatter: AgentFrontmatter,
|
||||
rawFields: Record<string, unknown>,
|
||||
): unknown | undefined {
|
||||
if (!isStandardKey(field)) {
|
||||
return Object.hasOwn(rawFields, field) ? rawFields[field] : undefined;
|
||||
}
|
||||
|
||||
const coerced = pickStandardField(frontmatter, field);
|
||||
if (coerced !== null) {
|
||||
return coerced;
|
||||
}
|
||||
return Object.hasOwn(rawFields, field) ? rawFields[field] : coerced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a CAS candidate object from schema property keys and parsed frontmatter.
|
||||
*
|
||||
* When the schema has no inspectable properties, falls back to the standard
|
||||
* agent frontmatter field (status only).
|
||||
*/
|
||||
function buildCandidate(
|
||||
frontmatter: AgentFrontmatter,
|
||||
rawFields: Record<string, unknown>,
|
||||
schemaFields: string[],
|
||||
): Record<string, unknown> {
|
||||
if (schemaFields.length === 0) {
|
||||
return defaultCandidate(frontmatter);
|
||||
}
|
||||
|
||||
const candidate: Record<string, unknown> = {};
|
||||
|
||||
for (const field of schemaFields) {
|
||||
const value = pickFieldValue(field, frontmatter, rawFields);
|
||||
if (value !== undefined) {
|
||||
candidate[field] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to satisfy `outputSchema` from frontmatter fields alone.
|
||||
*
|
||||
* Returns a result containing the stored CAS hash and stripped body on success,
|
||||
* or `null` when frontmatter is absent, invalid, or does not satisfy the schema.
|
||||
* Never throws.
|
||||
*
|
||||
* The candidate object is put into the real CAS store (idempotent content-addressed
|
||||
* write) and validated against the output schema. If validation fails the node
|
||||
* is orphaned — it will be GC'd on the next collection pass.
|
||||
*/
|
||||
export async function tryFrontmatterFastPath(
|
||||
raw: string,
|
||||
outputSchema: CasRef,
|
||||
store: Store,
|
||||
): Promise<FrontmatterFastPathResult | null> {
|
||||
const { frontmatter, body } = parseFrontmatterMarkdown(raw);
|
||||
|
||||
if (frontmatter === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validationErrors = validateFrontmatter(frontmatter);
|
||||
if (validationErrors.length > 0) {
|
||||
log(
|
||||
"9GNPS4WY",
|
||||
`frontmatter validation errors: ${validationErrors.map((e) => e.message).join("; ")}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const schema = getSchema(store, outputSchema);
|
||||
if (schema === null) {
|
||||
log("8FHMR2QX", `output schema not found in CAS: ${outputSchema}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const schemaFields = extractSchemaFields(schema);
|
||||
const rawFields = parseRawFrontmatterFields(raw);
|
||||
const candidate = buildCandidate(frontmatter, rawFields, schemaFields);
|
||||
|
||||
let outputHash: CasRef;
|
||||
let node: ReturnType<Store["get"]>;
|
||||
|
||||
try {
|
||||
outputHash = await store.put(outputSchema, candidate);
|
||||
node = store.get(outputHash);
|
||||
} catch {
|
||||
log("2KMQT7NR", "failed to store frontmatter candidate in CAS");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node === null || !validate(store, node)) {
|
||||
log("2KMQT7NR", "stored frontmatter candidate failed schema validation");
|
||||
return null;
|
||||
}
|
||||
|
||||
return { body, outputHash, frontmatter: candidate };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export { buildContinuationPrompt } from "./build-continuation-prompt.js";
|
||||
export { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
|
||||
export { buildRolePrompt } from "./build-role-prompt.js";
|
||||
export type { BuildContextMeta } from "./context.js";
|
||||
export { buildContext, buildContextWithMeta } from "./context.js";
|
||||
export type { ExtractResult, ResolvedLlmProvider } from "./extract.js";
|
||||
export {
|
||||
extract,
|
||||
resolveExtractModelAlias,
|
||||
resolveModel,
|
||||
} from "./extract.js";
|
||||
export type { FrontmatterFastPathResult } from "./frontmatter.js";
|
||||
export { tryFrontmatterFastPath } from "./frontmatter.js";
|
||||
export { createAgent, parseArgv } from "./run.js";
|
||||
export { getCachedSessionId, getCachePath, setCachedSessionId } from "./session-cache.js";
|
||||
export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
|
||||
export type {
|
||||
AdapterOutput,
|
||||
AgentContext,
|
||||
AgentContinueFn,
|
||||
AgentOptions,
|
||||
AgentRunFn,
|
||||
AgentRunResult,
|
||||
} from "./types.js";
|
||||
@@ -0,0 +1,218 @@
|
||||
import { getSchema, validate } from "@ocas/core";
|
||||
import type { CasRef, StepNodePayload, ThreadId } from "@united-workforce/protocol";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
|
||||
import { buildContextWithMeta } from "./context.js";
|
||||
import { tryFrontmatterFastPath } from "./frontmatter.js";
|
||||
import type { AgentStore } from "./storage.js";
|
||||
import { getEnvPath, resolveStorageRoot } from "./storage.js";
|
||||
import type { AdapterOutput, AgentOptions } from "./types.js";
|
||||
|
||||
const MAX_FRONTMATTER_RETRIES = 2;
|
||||
|
||||
function fail(message: string): never {
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function agentLabel(name: string): string {
|
||||
if (name.startsWith("uwf-")) {
|
||||
return name;
|
||||
}
|
||||
return `uwf-${name}`;
|
||||
}
|
||||
|
||||
const USAGE = "usage: <agent-cli> --thread <id> --role <role> --prompt <text>";
|
||||
|
||||
function getNamedArg(argv: string[], name: string): string {
|
||||
const idx = argv.indexOf(name);
|
||||
if (idx === -1 || idx + 1 >= argv.length) {
|
||||
return "";
|
||||
}
|
||||
return argv[idx + 1];
|
||||
}
|
||||
|
||||
export function parseArgv(argv: string[]): { threadId: ThreadId; role: string; prompt: string } {
|
||||
const threadId = getNamedArg(argv, "--thread");
|
||||
const role = getNamedArg(argv, "--role");
|
||||
const prompt = getNamedArg(argv, "--prompt");
|
||||
if (threadId === "") fail(USAGE);
|
||||
if (role === "") fail(USAGE);
|
||||
if (prompt === "")
|
||||
fail(
|
||||
`--prompt is empty. If this agent was spawned by uwf, the edge prompt template may have unresolved variables. ${USAGE}`,
|
||||
);
|
||||
return { threadId: threadId as ThreadId, role, prompt };
|
||||
}
|
||||
|
||||
function runWithMessage<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||
return fn().catch((e: unknown) => {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
fail(`${label}: ${message}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function writeStepNode(options: {
|
||||
store: AgentStore["store"];
|
||||
schemas: AgentStore["schemas"];
|
||||
startHash: CasRef;
|
||||
prevHash: CasRef | null;
|
||||
role: string;
|
||||
outputHash: CasRef;
|
||||
detailHash: CasRef;
|
||||
agentName: string;
|
||||
edgePrompt: string;
|
||||
startedAtMs: number;
|
||||
completedAtMs: number;
|
||||
assembledPromptHash: CasRef | null;
|
||||
}): Promise<CasRef> {
|
||||
const payload: StepNodePayload = {
|
||||
start: options.startHash,
|
||||
prev: options.prevHash,
|
||||
role: options.role,
|
||||
output: options.outputHash,
|
||||
detail: options.detailHash,
|
||||
agent: options.agentName,
|
||||
edgePrompt: options.edgePrompt,
|
||||
startedAtMs: options.startedAtMs,
|
||||
completedAtMs: options.completedAtMs,
|
||||
cwd: process.cwd(),
|
||||
assembledPrompt: options.assembledPromptHash,
|
||||
};
|
||||
const hash = await options.store.put(options.schemas.stepNode, payload);
|
||||
const node = options.store.get(hash);
|
||||
if (node === null || !validate(options.store, node)) {
|
||||
fail("stored StepNode failed schema validation");
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
type ExtractedOutput = {
|
||||
outputHash: CasRef;
|
||||
frontmatter: Record<string, unknown>;
|
||||
body: string;
|
||||
};
|
||||
|
||||
async function tryExtractOutput(
|
||||
rawOutput: string,
|
||||
outputSchema: CasRef,
|
||||
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>,
|
||||
): Promise<ExtractedOutput | null> {
|
||||
const fastPath = await tryFrontmatterFastPath(rawOutput, outputSchema, ctx.meta.store);
|
||||
if (fastPath !== null) {
|
||||
return {
|
||||
outputHash: fastPath.outputHash,
|
||||
frontmatter: fastPath.frontmatter,
|
||||
body: fastPath.body,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function persistStep(options: {
|
||||
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>;
|
||||
outputHash: CasRef;
|
||||
detailHash: CasRef;
|
||||
agentName: string;
|
||||
startedAtMs: number;
|
||||
completedAtMs: number;
|
||||
assembledPromptHash: CasRef | null;
|
||||
}): Promise<CasRef> {
|
||||
const { store, schemas, chain, headHash } = options.ctx.meta;
|
||||
return writeStepNode({
|
||||
store,
|
||||
schemas,
|
||||
startHash: chain.startHash,
|
||||
prevHash: chain.headIsStart ? null : headHash,
|
||||
role: options.ctx.role,
|
||||
outputHash: options.outputHash,
|
||||
detailHash: options.detailHash,
|
||||
agentName: options.agentName,
|
||||
edgePrompt: options.ctx.edgePrompt,
|
||||
startedAtMs: options.startedAtMs,
|
||||
completedAtMs: options.completedAtMs,
|
||||
assembledPromptHash: options.assembledPromptHash,
|
||||
});
|
||||
}
|
||||
|
||||
export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
return async function main(): Promise<void> {
|
||||
const { threadId, role, prompt } = parseArgv(process.argv);
|
||||
const storageRoot = resolveStorageRoot();
|
||||
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||
|
||||
const ctx = await runWithMessage("context", () => buildContextWithMeta(threadId, role, prompt));
|
||||
|
||||
const roleDef = ctx.workflow.roles[role];
|
||||
if (roleDef === undefined) {
|
||||
fail(`unknown role: ${role}`);
|
||||
}
|
||||
|
||||
const frontmatterSchema = getSchema(ctx.meta.store, roleDef.frontmatter);
|
||||
if (frontmatterSchema !== null) {
|
||||
ctx.outputFormatInstruction = buildOutputFormatInstruction(frontmatterSchema);
|
||||
}
|
||||
|
||||
const startedAtMs = Date.now();
|
||||
let agentResult = await runWithMessage("agent run failed", () => options.run(ctx));
|
||||
agentResult.output = agentResult.output.trimStart();
|
||||
|
||||
// Preserve the primary detail from the first run — it contains the full
|
||||
// tool-call turn history. Continuation retries only fix frontmatter
|
||||
// formatting and their 1-turn detail is not meaningful.
|
||||
const primaryDetailHash = agentResult.detailHash;
|
||||
|
||||
// Try to extract frontmatter; retry via continue if it fails
|
||||
let extracted = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
|
||||
for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && extracted === null; retry++) {
|
||||
const correctionMessage =
|
||||
"Your previous response did not contain valid YAML frontmatter matching the role schema.\n" +
|
||||
"You MUST begin your response with a YAML frontmatter block (--- delimited).\n" +
|
||||
"Please output ONLY the corrected frontmatter block followed by your work.";
|
||||
|
||||
agentResult = await runWithMessage("agent continue failed", () =>
|
||||
options.continue(agentResult.sessionId, correctionMessage, ctx.meta.store),
|
||||
);
|
||||
agentResult.output = agentResult.output.trimStart();
|
||||
extracted = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
}
|
||||
|
||||
if (extracted === null) {
|
||||
fail(
|
||||
"Agent output does not contain valid YAML frontmatter matching the role schema " +
|
||||
`after ${MAX_FRONTMATTER_RETRIES} retries.\n` +
|
||||
`Raw output (first 500 chars): ${agentResult.output.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
const completedAtMs = Date.now();
|
||||
|
||||
// Store the assembled prompt in CAS for later inspection via `step read --prompt`
|
||||
const promptText = agentResult.assembledPrompt;
|
||||
const assembledPromptHash =
|
||||
promptText !== ""
|
||||
? await ctx.meta.store.put(ctx.meta.schemas.text, promptText).catch(() => null)
|
||||
: null;
|
||||
|
||||
const stepHash = await persistStep({
|
||||
ctx,
|
||||
outputHash: extracted.outputHash,
|
||||
detailHash: primaryDetailHash,
|
||||
agentName: agentLabel(options.name),
|
||||
startedAtMs,
|
||||
completedAtMs,
|
||||
assembledPromptHash,
|
||||
});
|
||||
|
||||
const adapterOutput: AdapterOutput = {
|
||||
stepHash,
|
||||
detailHash: primaryDetailHash,
|
||||
role,
|
||||
frontmatter: extracted.frontmatter,
|
||||
body: extracted.body,
|
||||
startedAtMs,
|
||||
completedAtMs,
|
||||
};
|
||||
process.stdout.write(`${JSON.stringify(adapterOutput)}\n`);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Hash, Store } from "@ocas/core";
|
||||
import { putSchema } from "@ocas/core";
|
||||
import { START_NODE_SCHEMA, STEP_NODE_SCHEMA, WORKFLOW_SCHEMA } from "@united-workforce/protocol";
|
||||
|
||||
export type UwfAgentSchemaHashes = {
|
||||
workflow: Hash;
|
||||
startNode: Hash;
|
||||
stepNode: Hash;
|
||||
text: Hash;
|
||||
};
|
||||
|
||||
const TEXT_SCHEMA = { type: "string" as const };
|
||||
|
||||
/**
|
||||
* Register Workflow, StartNode, and StepNode JSON Schemas in the CAS store.
|
||||
* Idempotent: safe to call on every agent invocation.
|
||||
*/
|
||||
export async function registerAgentSchemas(store: Store): Promise<UwfAgentSchemaHashes> {
|
||||
const [workflow, startNode, stepNode, text] = await Promise.all([
|
||||
putSchema(store, WORKFLOW_SCHEMA),
|
||||
putSchema(store, START_NODE_SCHEMA),
|
||||
putSchema(store, STEP_NODE_SCHEMA),
|
||||
putSchema(store, TEXT_SCHEMA),
|
||||
]);
|
||||
return { workflow, startNode, stepNode, text };
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import type { ThreadId } from "@united-workforce/protocol";
|
||||
|
||||
import { resolveStorageRoot } from "./storage.js";
|
||||
|
||||
type SessionCache = Record<string, string>;
|
||||
|
||||
export function getCachePath(agentName: string): string {
|
||||
return join(resolveStorageRoot(), "cache", `${agentName}-sessions.json`);
|
||||
}
|
||||
|
||||
function cacheKey(threadId: ThreadId, role: string): string {
|
||||
return `${threadId}:${role}`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function readCache(agentName: string): Promise<SessionCache> {
|
||||
const path = getCachePath(agentName);
|
||||
try {
|
||||
const text = await readFile(path, "utf8");
|
||||
const raw = JSON.parse(text) as unknown;
|
||||
if (!isRecord(raw)) {
|
||||
return {};
|
||||
}
|
||||
const cache: SessionCache = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (typeof value === "string" && value !== "") {
|
||||
cache[key] = value;
|
||||
}
|
||||
}
|
||||
return cache;
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException;
|
||||
if (err.code === "ENOENT") {
|
||||
return {};
|
||||
}
|
||||
// Treat JSON parse errors as empty cache
|
||||
if (err.name === "SyntaxError") {
|
||||
return {};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCache(agentName: string, cache: SessionCache): Promise<void> {
|
||||
const path = getCachePath(agentName);
|
||||
const dir = dirname(path);
|
||||
await mkdir(dir, { recursive: true });
|
||||
// Atomic write: write to temp file then rename to avoid partial reads on concurrent access.
|
||||
// NOTE: Current workflow execution is serial (execFileSync), so true concurrency doesn't occur.
|
||||
// This is a safety net for future parallel execution.
|
||||
const tmpPath = join(dir, `.${agentName}-sessions.${randomBytes(4).toString("hex")}.tmp`);
|
||||
await writeFile(tmpPath, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
|
||||
await rename(tmpPath, path);
|
||||
}
|
||||
|
||||
/** Read the cached session ID for a thread+role pair. */
|
||||
export async function getCachedSessionId(
|
||||
agentName: string,
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
): Promise<string | null> {
|
||||
const cache = await readCache(agentName);
|
||||
const sessionId = cache[cacheKey(threadId, role)];
|
||||
return sessionId ?? null;
|
||||
}
|
||||
|
||||
/** Write the session ID for a thread+role pair into the cache. */
|
||||
export async function setCachedSessionId(
|
||||
agentName: string,
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
const cache = await readCache(agentName);
|
||||
cache[cacheKey(threadId, role)] = sessionId;
|
||||
await writeCache(agentName, cache);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createVariableStore, type Store } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import type {
|
||||
AgentAlias,
|
||||
AgentConfig,
|
||||
CasRef,
|
||||
ModelAlias,
|
||||
ModelConfig,
|
||||
ProviderAlias,
|
||||
ProviderConfig,
|
||||
Scenario,
|
||||
ThreadId,
|
||||
ThreadIndexEntry,
|
||||
WorkflowConfig,
|
||||
WorkflowName,
|
||||
} from "@united-workforce/protocol";
|
||||
import { parse } from "yaml";
|
||||
|
||||
import { registerAgentSchemas } from "./schemas.js";
|
||||
|
||||
/** Default filesystem root for uwf data (`~/.uwf`). */
|
||||
export function getDefaultStorageRoot(): string {
|
||||
return join(homedir(), ".uwf");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve storage root.
|
||||
* Priority: `UWF_STORAGE_ROOT` → `WORKFLOW_STORAGE_ROOT` → `UNCAGED_WORKFLOW_STORAGE_ROOT` (legacy) → default.
|
||||
*/
|
||||
export function resolveStorageRoot(): string {
|
||||
const primary = process.env.UWF_STORAGE_ROOT;
|
||||
if (primary !== undefined && primary !== "") {
|
||||
return primary;
|
||||
}
|
||||
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
|
||||
if (userOverride !== undefined && userOverride !== "") {
|
||||
return userOverride;
|
||||
}
|
||||
const legacy = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
if (legacy !== undefined && legacy !== "") {
|
||||
return legacy;
|
||||
}
|
||||
return getDefaultStorageRoot();
|
||||
}
|
||||
|
||||
export function getCasDir(storageRoot: string): string {
|
||||
return join(storageRoot, "cas");
|
||||
}
|
||||
|
||||
export function getConfigPath(storageRoot: string): string {
|
||||
return join(storageRoot, "config.yaml");
|
||||
}
|
||||
|
||||
export function getEnvPath(storageRoot: string): string {
|
||||
return join(storageRoot, ".env");
|
||||
}
|
||||
|
||||
const THREAD_VAR_PREFIX = "@uwf/thread/";
|
||||
|
||||
/**
|
||||
* Global CAS directory (same as uwf CLI).
|
||||
* Priority: `OCAS_DIR` → `UNCAGED_CAS_DIR` (legacy) → default ~/.ocas
|
||||
*/
|
||||
export function getGlobalCasDir(): string {
|
||||
const primary = process.env.OCAS_DIR;
|
||||
if (primary !== undefined && primary !== "") {
|
||||
return primary;
|
||||
}
|
||||
const legacy = process.env.UNCAGED_CAS_DIR;
|
||||
if (legacy !== undefined && legacy !== "") {
|
||||
return legacy;
|
||||
}
|
||||
return join(homedir(), ".ocas");
|
||||
}
|
||||
|
||||
function threadVarName(threadId: ThreadId): string {
|
||||
return `${THREAD_VAR_PREFIX}${threadId}`;
|
||||
}
|
||||
|
||||
/** Read active thread head + suspend metadata from ocas variable store. */
|
||||
export async function getActiveThreadEntry(
|
||||
_storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
): Promise<ThreadIndexEntry | null> {
|
||||
const casDir = getGlobalCasDir();
|
||||
const store = createFsStore(casDir);
|
||||
const varStore = createVariableStore(join(casDir, "variables.db"), store);
|
||||
const vars = varStore.list({ exactName: threadVarName(threadId) });
|
||||
const v = vars[0];
|
||||
if (v === undefined) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
head: v.value as CasRef,
|
||||
suspendedRole: v.tags.suspendedRole ?? null,
|
||||
suspendMessage: v.tags.suspendMessage ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentStore = {
|
||||
storageRoot: string;
|
||||
store: Store;
|
||||
schemas: Awaited<ReturnType<typeof registerAgentSchemas>>;
|
||||
};
|
||||
|
||||
export async function createAgentStore(storageRoot: string): Promise<AgentStore> {
|
||||
const store = createFsStore(getGlobalCasDir());
|
||||
const schemas = await registerAgentSchemas(store);
|
||||
return { storageRoot, store, schemas };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeProviders(raw: unknown): Record<ProviderAlias, ProviderConfig> {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("config.providers must be a mapping");
|
||||
}
|
||||
const providers: Record<ProviderAlias, ProviderConfig> = {};
|
||||
for (const [name, entry] of Object.entries(raw)) {
|
||||
if (!isRecord(entry)) {
|
||||
throw new Error(`config.providers.${name} must be a mapping`);
|
||||
}
|
||||
const baseUrl = entry.baseUrl;
|
||||
const apiKey = entry.apiKey;
|
||||
if (typeof baseUrl !== "string" || typeof apiKey !== "string") {
|
||||
throw new Error(`config.providers.${name} requires baseUrl and apiKey`);
|
||||
}
|
||||
providers[name] = { baseUrl, apiKey };
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
function normalizeModels(raw: unknown): Record<ModelAlias, ModelConfig> {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("config.models must be a mapping");
|
||||
}
|
||||
const models: Record<ModelAlias, ModelConfig> = {};
|
||||
for (const [name, entry] of Object.entries(raw)) {
|
||||
if (!isRecord(entry)) {
|
||||
throw new Error(`config.models.${name} must be a mapping`);
|
||||
}
|
||||
const provider = entry.provider;
|
||||
const modelName = entry.name;
|
||||
if (typeof provider !== "string" || typeof modelName !== "string") {
|
||||
throw new Error(`config.models.${name} requires provider and name`);
|
||||
}
|
||||
models[name] = { provider, name: modelName };
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
function normalizeAgents(raw: unknown): Record<AgentAlias, AgentConfig> {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("config.agents must be a mapping");
|
||||
}
|
||||
const agents: Record<AgentAlias, AgentConfig> = {};
|
||||
for (const [name, entry] of Object.entries(raw)) {
|
||||
if (!isRecord(entry)) {
|
||||
throw new Error(`config.agents.${name} must be a mapping`);
|
||||
}
|
||||
const command = entry.command;
|
||||
const argsRaw = entry.args;
|
||||
if (typeof command !== "string") {
|
||||
throw new Error(`config.agents.${name} requires command`);
|
||||
}
|
||||
const args = Array.isArray(argsRaw)
|
||||
? argsRaw.filter((a): a is string => typeof a === "string")
|
||||
: [];
|
||||
agents[name] = { command, args };
|
||||
}
|
||||
return agents;
|
||||
}
|
||||
|
||||
function normalizeModelOverrides(raw: unknown): Record<Scenario, ModelAlias> | null {
|
||||
if (raw === undefined || raw === null) {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("config.modelOverrides must be a mapping or null");
|
||||
}
|
||||
const overrides: Record<Scenario, ModelAlias> = {};
|
||||
for (const [scene, alias] of Object.entries(raw)) {
|
||||
if (typeof alias === "string") {
|
||||
overrides[scene] = alias;
|
||||
}
|
||||
}
|
||||
return overrides;
|
||||
}
|
||||
|
||||
function normalizeAgentOverrides(
|
||||
raw: unknown,
|
||||
): Record<WorkflowName, Record<string, AgentAlias>> | null {
|
||||
if (raw === undefined || raw === null) {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("config.agentOverrides must be a mapping or null");
|
||||
}
|
||||
const overrides: Record<WorkflowName, Record<string, AgentAlias>> = {};
|
||||
for (const [workflowName, rolesRaw] of Object.entries(raw)) {
|
||||
if (!isRecord(rolesRaw)) {
|
||||
continue;
|
||||
}
|
||||
const roles: Record<string, AgentAlias> = {};
|
||||
for (const [roleName, alias] of Object.entries(rolesRaw)) {
|
||||
if (typeof alias === "string") {
|
||||
roles[roleName] = alias;
|
||||
}
|
||||
}
|
||||
overrides[workflowName] = roles;
|
||||
}
|
||||
return overrides;
|
||||
}
|
||||
|
||||
export function normalizeWorkflowConfig(raw: unknown): WorkflowConfig {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("config.yaml root must be a mapping");
|
||||
}
|
||||
const defaultAgent = raw.defaultAgent;
|
||||
const defaultModel = raw.defaultModel;
|
||||
if (typeof defaultAgent !== "string" || typeof defaultModel !== "string") {
|
||||
throw new Error("config requires defaultAgent and defaultModel");
|
||||
}
|
||||
return {
|
||||
providers: normalizeProviders(raw.providers),
|
||||
models: normalizeModels(raw.models),
|
||||
agents: normalizeAgents(raw.agents),
|
||||
defaultAgent,
|
||||
agentOverrides: normalizeAgentOverrides(raw.agentOverrides),
|
||||
defaultModel,
|
||||
modelOverrides: normalizeModelOverrides(raw.modelOverrides),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadWorkflowConfig(storageRoot: string): Promise<WorkflowConfig> {
|
||||
const path = getConfigPath(storageRoot);
|
||||
const text = await readFile(path, "utf8");
|
||||
const raw = parse(text) as unknown;
|
||||
return normalizeWorkflowConfig(raw);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Store } from "@ocas/core";
|
||||
import type { ModeratorContext, ThreadId, WorkflowPayload } from "@united-workforce/protocol";
|
||||
|
||||
export type AgentContext = ModeratorContext & {
|
||||
threadId: ThreadId;
|
||||
role: string;
|
||||
store: Store;
|
||||
workflow: WorkflowPayload;
|
||||
/**
|
||||
* Prepend to the role's prompt when building the agent prompt.
|
||||
* Contains the frontmatter deliverable format instruction derived from the
|
||||
* role's output schema. Populated by `createAgent` at run time.
|
||||
*/
|
||||
outputFormatInstruction: string;
|
||||
/**
|
||||
* Edge prompt from the graph transition that led to this role (--prompt CLI arg).
|
||||
* Always the real moderator instruction for this step.
|
||||
*/
|
||||
edgePrompt: string;
|
||||
/**
|
||||
* True when the current role has not appeared in steps history before this invocation.
|
||||
*/
|
||||
isFirstVisit: boolean;
|
||||
};
|
||||
|
||||
export type AgentRunResult = {
|
||||
output: string;
|
||||
detailHash: string;
|
||||
sessionId: string;
|
||||
/** The fully assembled prompt that was sent to the agent. */
|
||||
assembledPrompt: string;
|
||||
};
|
||||
|
||||
export type AgentContinueFn = (
|
||||
sessionId: string,
|
||||
message: string,
|
||||
store: AgentContext["store"],
|
||||
) => Promise<AgentRunResult>;
|
||||
|
||||
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
|
||||
|
||||
export type AdapterOutput = {
|
||||
stepHash: string;
|
||||
detailHash: string;
|
||||
role: string;
|
||||
frontmatter: Record<string, unknown>;
|
||||
body: string;
|
||||
startedAtMs: number;
|
||||
completedAtMs: number;
|
||||
};
|
||||
|
||||
export type AgentOptions = {
|
||||
name: string;
|
||||
run: AgentRunFn;
|
||||
continue: AgentContinueFn;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../protocol" }, { "path": "../util" }]
|
||||
}
|
||||
Reference in New Issue
Block a user