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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ plans/
tmp/
.mb-scratch/
.corbits/
packages/longevity-sim/output/
15 changes: 15 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/longevity-sim/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
output/
176 changes: 176 additions & 0 deletions packages/longevity-sim/LICENSE

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions packages/longevity-sim/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# @corbits/longevity-sim

Longevity simulation harness (CL-6440): drives a real workbench stack
(hub + sidecar + Postgres) through time-compressed team life — ~10
simulated sales humans, agents, and routines. The humans are scripted
(deterministic posts, replies, mentions), but every agent turn is real
inference against the owner's Ollama fleet, spread across multiple
models and base URLs — there is no stub or noop inference path.
Checkpoints track degradation metrics and self-improvement checks as
the campaign runs.

## This package's slice: the pure core

Everything under `src/` here is pure and deterministic given a `seed` —
no network, no process spawning, no filesystem access. It owns:

- `prng.ts` — seeded RNG (`createRng`, `pick`).
- `personas.ts` — the fictional 10-person sales team and their
deterministic chatter (`SALES_TEAM`, `utterance`).
- `config.ts` — `CampaignConfig`, arktype-validated
(`campaignConfig`, `parseCampaignConfig`).
- `plan.ts` — turns a `CampaignConfig` into an ordered `PlanStep[]`
(`buildPlan`, `summarizePlan`).
- `metrics.ts` — percentile math and knee detection over
`CheckpointRecord`s (`percentile`, `findKnees`).
- `report.ts` — renders a `CampaignReport` to markdown
(`renderCampaignReport`, `reportVerdict`).

A separate stack layer (`stack.ts`, `engine.ts`, `probes.ts`, `cli.ts`)
drives the actual HTTP stack and boot glue against this plan; it is out
of scope for this slice and not exported from `src/index.ts` yet.

## Scripts

- `bun run typecheck`
- `bun test`
24 changes: 24 additions & 0 deletions packages/longevity-sim/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@corbits/longevity-sim",
"private": true,
"description": "Longevity simulation harness (CL-6440): drives a real workbench stack through time-compressed team life, checkpointing degradation metrics and self-improvement checks against a deterministic simulated sales campaign.",
"version": "0.0.1",
"license": "LGPL-2.1-or-later",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "bun test"
},
"dependencies": {
"@intx/agent": "0.3.0",
"@intx/workflow": "workspace:*",
"arktype": "catalog:"
},
"devDependencies": {
"@types/bun": "catalog:",
"typescript": "catalog:"
}
}
70 changes: 70 additions & 0 deletions packages/longevity-sim/src/agent-workflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// The campaign's own conversational agent definition: a mail-triggered
// single-step agent with a per-agent system prompt, shaped exactly like
// the seeded `workflows/assistant` definition (single step, unbounded
// triggers, explicit timeout) but with the prompt as input — the seeded
// builder hardcodes its prompt, and the campaign needs distinct
// personas plus mid-campaign prompt changes (skill-marker redeploys).
//
// The declared `inference.sources` MUST equal the deploy body's
// `(provider, model)` pair: the deploy gate approves exactly the pairs
// the agent declares (vendor/intx/workflow-deploy orchestrator), and
// the sidecar rejects sources whose provider is not a registered
// adapter — so a `catalog:`-style placeholder cannot deploy through
// the public deployments route.

import { defineAgent } from "@intx/agent";
import type { InferencePreference } from "@intx/agent";
import { defineWorkflow, step } from "@intx/workflow";
import type { WorkflowDefinition } from "@intx/workflow";

export interface CampaignAgentWorkflowInput {
readonly handle: string;
readonly tenantDomain: string;
readonly description: string;
readonly systemPrompt: string;
readonly inferencePreferences: readonly InferencePreference[];
readonly turnTimeoutMs: number;
}

export function buildCampaignAgentWorkflow(
input: CampaignAgentWorkflowInput,
): WorkflowDefinition {
if (input.handle === "") {
throw new Error("buildCampaignAgentWorkflow requires a non-empty handle");
}
if (input.systemPrompt === "") {
throw new Error(
"buildCampaignAgentWorkflow requires a non-empty systemPrompt",
);
}
if (!Number.isInteger(input.turnTimeoutMs) || input.turnTimeoutMs <= 0) {
throw new Error(
"buildCampaignAgentWorkflow requires turnTimeoutMs to be a positive integer",
);
}
const stepId = "agent";
return defineWorkflow({
id: `wf_agent_${input.handle}`,
trigger: { type: "mail", to: `${input.handle}@${input.tenantDomain}` },
steps: {
[stepId]: step({
agent: defineAgent({
id: stepId,
description: input.description,
systemPrompt: input.systemPrompt,
tools: [],
capabilities: [],
inference: { sources: input.inferencePreferences },
}),
timeout: input.turnTimeoutMs,
triggers: "unbounded",
}),
},
});
}

export function serializeCampaignAgentWorkflow(
definition: WorkflowDefinition,
): string {
return JSON.stringify(definition);
}
178 changes: 178 additions & 0 deletions packages/longevity-sim/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// `bun src/cli.ts --config <path.json>`: reads DATABASE_URL and a
// REQUIRED REAL_TARGETS env (JSON array of `InferenceTarget` — each a
// real Ollama origin dialed through the `openai-compatible` adapter,
// e.g. `baseURL: "https://<tailscale-host>/v1"`), boots the stack,
// builds and executes the plan, writes the markdown report, prints its
// path and verdict, and exits 1 on any S1 defect. There is no
// noop/Anthropic fallback mode: every agent this CLI creates is a real
// agent pinned at one of REAL_TARGETS' catalog models.

import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { type } from "arktype";

import { parseCampaignConfig } from "./config";
import { buildPlan, MENTION_AGENT_KEY, REAL_AGENT_KEY } from "./plan";
import { renderCampaignReport, reportVerdict } from "./report";
import { SALES_TEAM } from "./personas";
import {
bootLongevityStack,
type AgentDefinitionSpec,
type InferenceTarget,
type RoutineSpec,
type SkillSpec,
} from "./stack";
import { executeCampaign } from "./engine";

const inferenceTarget = type({
label: "string",
provider: "string",
model: "string",
baseURL: "string",
apiKey: "string",
});
const realTargetsSchema = inferenceTarget.array();

/** REAL_TARGETS is required: this CLI seeds no noop/Anthropic fallback
* catalog chain, so a campaign with no real target has no model any
* agent could ever be pinned at. */
function parseRealTargets(raw: string | undefined): readonly InferenceTarget[] {
if (raw === undefined || raw.trim() === "") {
throw new Error(
"REAL_TARGETS is required (JSON array of InferenceTarget) — this " +
"campaign has no noop/Anthropic fallback; every agent needs a " +
"real Ollama target to pin its model at",
);
}
const parsed = realTargetsSchema(JSON.parse(raw));
if (parsed instanceof type.errors) {
throw new Error(`REAL_TARGETS: ${parsed.summary}`);
}
if (parsed.length === 0) {
throw new Error("REAL_TARGETS must name at least one InferenceTarget");
}
return parsed;
}

const SKILL_MARKER_NAME = "campaign-self-improvement";

/** One real agent definition per `realTargets` entry (cycling if the
* roster below outgrows the fleet) — every agent is real inference,
* pinned at that target's own catalog model. The fleet is expected to
* grow by adding more `realTargets` entries, never by this function
* inventing a stub/noop path. */
function buildAgentSpecs(
realTargets: readonly InferenceTarget[],
): AgentDefinitionSpec[] {
// Order matters twice: index 0 (the plan's measured-turn target) owns
// the marker skill, and the round-robin below maps roster order onto
// `realTargets` order — so the lead agent lands on the first target.
const roster = [
{ key: REAL_AGENT_KEY, handle: REAL_AGENT_KEY, name: "Sales Analyst" },
{ key: "deal-desk", handle: "deal-desk", name: "Deal Desk" },
{ key: "ops-analyst", handle: "ops-analyst", name: "Ops Analyst" },
{
key: MENTION_AGENT_KEY,
handle: MENTION_AGENT_KEY,
name: "Support Copilot",
},
];
return roster.map((entry, index) => {
const target = realTargets[index % realTargets.length];
if (target === undefined) {
throw new Error(
"unreachable: realTargets is non-empty by parseRealTargets",
);
}
return {
key: entry.key,
handle: entry.handle,
name: entry.name,
systemPrompt:
"You are a real-model assistant helping a sales team. Keep replies short and concrete.",
real: true,
targetLabel: target.label,
skills: index === 0 ? [SKILL_MARKER_NAME] : [],
};
});
}

const ROUTINE_SPECS: RoutineSpec[] = [
{ key: "heartbeat-1", name: "Daily heartbeat" },
];

function skillSpecs(): SkillSpec[] {
return [
{
name: SKILL_MARKER_NAME,
description: "Longevity campaign self-improvement marker skill.",
body: "Reply normally; this skill's instructions are updated during the campaign.",
},
];
}

function parseArgs(argv: readonly string[]): { configPath: string } {
const flagIndex = argv.indexOf("--config");
const configPath = flagIndex >= 0 ? argv[flagIndex + 1] : undefined;
if (configPath === undefined) {
throw new Error("usage: bun src/cli.ts --config <path.json>");
}
return { configPath };
}

async function main(): Promise<number> {
const { configPath } = parseArgs(process.argv.slice(2));
const configRaw = JSON.parse(await readFile(configPath, "utf8"));
const config = parseCampaignConfig(configRaw);

const databaseUrl = process.env["DATABASE_URL"];
if (databaseUrl === undefined || databaseUrl === "") {
throw new Error(
"DATABASE_URL is not set; the campaign needs a reachable Postgres",
);
}
const realTargets = parseRealTargets(process.env["REAL_TARGETS"]);

const stack = await bootLongevityStack(
SALES_TEAM,
buildAgentSpecs(realTargets),
ROUTINE_SPECS,
{ databaseUrl, realTargets, skills: skillSpecs() },
);

try {
const steps = buildPlan(config);
const report = await executeCampaign(stack, steps, config, {
onProgress: (info) => {
process.stdout.write(`[${info.atMessages}] ${info.kind}\n`);
},
});

const outputDir = path.join(import.meta.dir, "..", "output");
await mkdir(outputDir, { recursive: true });
const outputPath = path.join(outputDir, `${report.name}.md`);
await writeFile(outputPath, renderCampaignReport(report), "utf8");

process.stdout.write(`report written to ${outputPath}\n`);
process.stdout.write(`${reportVerdict(report)}\n`);

const hasS1Defect = report.defects.some(
(defect) => defect.severity === "S1",
);
return hasS1Defect ? 1 : 0;
} finally {
await stack.close();
}
}

// Top-level await (not a floating `main().then(...)` chain) is load-bearing:
// a detached promise does not keep Bun's event loop alive, and the boot
// sequence has handle-free moments where a drained loop exits 0 mid-campaign.
try {
process.exit(await main());
} catch (error) {
const detail =
error instanceof Error ? (error.stack ?? error.message) : String(error);
process.stderr.write(`${detail}\n`);
process.exit(1);
}
Loading
Loading