refactor: align package folder names with npm package names
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:
2026-06-02 23:45:45 +08:00
parent e4e4288d00
commit 5970456a54
266 changed files with 207 additions and 207 deletions
@@ -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");
}
+240
View File
@@ -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 },
};
}
+179
View File
@@ -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 };
}
+181
View File
@@ -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 };
}
+24
View File
@@ -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";
+218
View File
@@ -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`);
};
}
+26
View File
@@ -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 };
}
+84
View File
@@ -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);
}
+246
View File
@@ -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);
}
+56
View File
@@ -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;
};