Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

641 lines
22 KiB
Python

"""Multi-turn project simulator for synth trajectory generation.
Today's synth (`drive_eliza.py`, `together_synth.py`) is one-shot: each
scenario → one model call → one record. Real users drive *projects* —
multi-step goals where each turn depends on the previous response. The
fine-tuning corpus needs trajectories that look like that.
This module simulates a project. A "project" is an LLM-authored multi-step
goal, derived from either a seed corpus (curated good projects) or recent
benchmark failures (project shapes the current model fails on). At each
turn the simulator decides whether to:
- send the next user prompt and record (prompt, agent response),
- or terminate (goal reached / unrecoverable / max turns).
Every turn lands as one record in the output JSONL, tagged
``synth_kind: 'multi_turn_project'``, linked into a single trajectory
chain via ``parent_step_id`` (mirroring the in-runtime parent-step
linkage at
``plugins/plugin-agent-orchestrator/src/services/spawn-trajectory.ts``).
Architecture
============
CLI is a thin argparse wrapper. All orchestration lives in
``ProjectSimulator``. Three pluggable interfaces (typing.Protocol) let
the same loop run against:
- fixture models (for unit/e2e tests, no network),
- Together / Anthropic / OpenAI-compatible HTTP endpoints,
- the eliza benchmark server (`drive_eliza.py` semantics).
The agent transport is supplied by the caller — this file does not own
HTTP code, on purpose. Privacy filtering is NOT this module's concern
(repo-wide CLAUDE.md / training AGENTS.md require the
``validate_corpus.py`` + privacy filter passes downstream).
Usage
=====
::
python -m synth.project_simulator \\
--seed-file scripts/synth/scenarios/all.jsonl \\
--turns 10 \\
--output-dir data/synthesized/multi_turn_project/ \\
--num-projects 50
Records (one per turn) look like::
{
"synth_kind": "multi_turn_project",
"session_id": "proj-<uuid>",
"project": {
"id": "proj-<uuid>",
"title": "Plan a 3-stop weekend road trip",
"goal": "Get a confirmed itinerary with hotels and routes."
},
"turn_index": 2,
"parent_step_id": "step-<uuid-of-turn-1>",
"step_id": "step-<uuid-of-turn-2>",
"messages": [
{"role": "system", "content": "..."},
{"role": "user", "content": "..."},
{"role": "assistant", "content": "..."}
],
"user_text": "...",
"agent_text": "...",
"termination": null // or {"reason": "goal_reached" | "max_turns" | "unrecoverable"}
}
"""
from __future__ import annotations
import argparse
import json
import logging
import random
import sys
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable, Iterator, Protocol
# stable tag for downstream filters / corpus assemblers
SYNTH_KIND = "multi_turn_project"
# ─────────────────────────── domain types ────────────────────────────
@dataclass(frozen=True)
class Project:
"""An LLM-authored (or seed-derived) multi-step user goal."""
id: str
title: str
goal: str
initial_user_text: str
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class Message:
role: str # "user" | "assistant" | "system"
content: str
class TerminationReason:
GOAL_REACHED = "goal_reached"
MAX_TURNS = "max_turns"
UNRECOVERABLE = "unrecoverable"
@dataclass(frozen=True)
class TurnDecision:
"""What the simulator does at a turn boundary."""
terminate: bool
reason: str | None = None # one of TerminationReason.*
next_user_text: str | None = None
# ─────────────────────────── protocols ────────────────────────────
class ProjectAuthor(Protocol):
"""Generate `Project` instances. Used for both seed-corpus and
benchmark-failure-driven authoring. Implementations may be deterministic
(fixture for tests) or LLM-backed (Together / Anthropic) for production.
"""
def author(self, *, n: int, rng: random.Random) -> list[Project]: ...
class AgentClient(Protocol):
"""Send a turn to the agent under test and get back the model output."""
def respond(self, *, project: Project, history: list[Message]) -> str: ...
class TurnDecider(Protocol):
"""Given the project + history so far, decide whether to terminate
or what the next user prompt should be.
A real implementation will call an LLM to play the user. A fixture
implementation can read from a scripted plan.
"""
def decide(
self,
*,
project: Project,
history: list[Message],
turn_index: int,
max_turns: int,
) -> TurnDecision: ...
# ─────────────────────────── seed-file authoring ────────────────────────────
def load_seed_records(seed_path: Path) -> list[dict[str, Any]]:
"""Read a JSONL seed file (scenario shape; see drive_eliza.py)."""
out: list[dict[str, Any]] = []
with seed_path.open() as f:
for line in f:
line = line.strip()
if not line:
continue
out.append(json.loads(line))
return out
class SeedFileProjectAuthor:
"""Author projects from a seed JSONL of scenarios.
Each scenario becomes one project: the scenario's ``user_text`` is the
initial user message; the project goal is derived from the scenario's
``benchmark`` / ``task_id``. This is the deterministic path used by
tests; the LLM-backed path lives in ``LLMProjectAuthor`` (below).
"""
def __init__(self, seed_records: list[dict[str, Any]]):
if not seed_records:
raise ValueError("SeedFileProjectAuthor: seed_records is empty")
self._records = list(seed_records)
def author(self, *, n: int, rng: random.Random) -> list[Project]:
sample = (
rng.sample(self._records, n)
if n <= len(self._records)
else [rng.choice(self._records) for _ in range(n)]
)
projects: list[Project] = []
for rec in sample:
task_id = rec.get("task_id") or f"seed-{uuid.uuid4()}"
text = rec.get("user_text") or ""
if not text:
continue
benchmark = rec.get("benchmark", "synth")
projects.append(
Project(
id=f"proj-{uuid.uuid4().hex[:12]}",
title=str(task_id)[:80],
goal=f"Complete the multi-step request: {text[:120]}",
initial_user_text=text,
metadata={
"seed_task_id": task_id,
"seed_benchmark": benchmark,
"seed_context": rec.get("context", {}),
},
)
)
return projects
# ─────────────────────────── fixture transports ────────────────────────────
class ScriptedTurnDecider:
"""Deterministic turn decider for tests.
Configured with a per-project plan listing what happens AFTER each
completed turn. Index ``i`` of the plan is the decision made after
turn ``i`` (which is itself indexed from 0). The value is either:
- a string: the next user prompt for turn ``i + 1``,
- ``None``: terminate after turn ``i`` (reason = GOAL_REACHED).
Examples:
- ``[]`` → terminate immediately after turn 0 (1 total turn).
- ``["a"]`` → after turn 0, send "a" as turn 1; the simulator
then runs out of plan → terminate (2 total turns).
- ``["a", None]`` → after turn 0, send "a"; after turn 1, terminate
(2 total turns, explicit GOAL_REACHED).
- ``["a", "b", None]`` → 3 total turns; explicit GOAL_REACHED.
Plans missing for a project default to "terminate immediately after
turn 0".
"""
def __init__(self, plan: dict[str, list[str | None]]):
self._plan = plan
def decide(
self,
*,
project: Project,
history: list[Message],
turn_index: int,
max_turns: int,
) -> TurnDecision:
# turn_index here is the *next* turn that would be executed
# (the simulator calls this after completing turn N with
# turn_index = N + 1).
if turn_index >= max_turns:
return TurnDecision(terminate=True, reason=TerminationReason.MAX_TURNS)
steps = self._plan.get(project.id, [])
# plan[i] describes the decision after completing turn i;
# so for the call after turn N, we read plan[N] which is at
# index turn_index - 1.
plan_idx = turn_index - 1
if plan_idx < 0 or plan_idx >= len(steps):
return TurnDecision(terminate=True, reason=TerminationReason.GOAL_REACHED)
nxt = steps[plan_idx]
if nxt is None:
return TurnDecision(terminate=True, reason=TerminationReason.GOAL_REACHED)
return TurnDecision(terminate=False, next_user_text=nxt)
class EchoAgentClient:
"""Fixture AgentClient: deterministic, no network.
Returns a short, traceable response based on the last user message
plus the turn index. Good enough for asserting trajectory shape +
parent-step linkage in unit tests.
"""
def respond(self, *, project: Project, history: list[Message]) -> str:
last_user = next(
(m.content for m in reversed(history) if m.role == "user"),
"",
)
n_user = sum(1 for m in history if m.role == "user")
return f"[fixture-agent t{n_user}] acknowledged: {last_user[:120]}"
# ─────────────────────────── default open-turn decider ────────────────────────────
class HeuristicTurnDecider:
"""A simple decider used when no LLM-backed decider is wired in.
It terminates when the most recent agent response mentions a goal-
completion sentinel (configurable), or after ``max_turns``. Otherwise
it advances by appending a short follow-up prompt taken from a
rotating pool. Useful for boot-strapping multi-turn data when no
author LLM is available; production runs replace this with an
LLM-backed decider.
"""
DEFAULT_FOLLOWUPS = (
"Great. What's next?",
"Can you walk through the next step?",
"Sounds good — keep going.",
"Anything else I should confirm?",
"Let's continue.",
)
DEFAULT_DONE_SENTINELS = ("done", "all set", "finished", "complete")
def __init__(
self,
*,
followups: Iterable[str] = DEFAULT_FOLLOWUPS,
done_sentinels: Iterable[str] = DEFAULT_DONE_SENTINELS,
):
self._followups = list(followups)
if not self._followups:
raise ValueError("HeuristicTurnDecider: followups must be non-empty")
self._done = tuple(s.lower() for s in done_sentinels)
def decide(
self,
*,
project: Project,
history: list[Message],
turn_index: int,
max_turns: int,
) -> TurnDecision:
if turn_index >= max_turns:
return TurnDecision(terminate=True, reason=TerminationReason.MAX_TURNS)
last_agent = next(
(m.content for m in reversed(history) if m.role == "assistant"),
"",
)
if any(s in last_agent.lower() for s in self._done):
return TurnDecision(terminate=True, reason=TerminationReason.GOAL_REACHED)
return TurnDecision(
terminate=False,
next_user_text=self._followups[turn_index % len(self._followups)],
)
# ─────────────────────────── core orchestrator ────────────────────────────
@dataclass
class TurnRecord:
"""One turn = one JSONL row. Multiple records share a session_id and
link via parent_step_id → step_id, mirroring the runtime's spawn-
trajectory chain (see spawn-trajectory.ts).
"""
synth_kind: str
session_id: str
project: dict[str, Any]
turn_index: int
parent_step_id: str | None
step_id: str
messages: list[dict[str, str]]
user_text: str
agent_text: str
termination: dict[str, str] | None
def to_dict(self) -> dict[str, Any]:
return {
"synth_kind": self.synth_kind,
"session_id": self.session_id,
"project": self.project,
"turn_index": self.turn_index,
"parent_step_id": self.parent_step_id,
"step_id": self.step_id,
"messages": self.messages,
"user_text": self.user_text,
"agent_text": self.agent_text,
"termination": self.termination,
}
def _new_step_id() -> str:
return f"step-{uuid.uuid4().hex[:16]}"
def _new_session_id(project_id: str) -> str:
# session shares the project id; useful for grouping in BI / eval views
return project_id
class ProjectSimulator:
"""Drive a project through up to ``max_turns`` turns.
The simulator is transport-agnostic — it does not know about HTTP,
Together, or anthropic SDKs. The caller injects:
- an :class:`AgentClient` that knows how to talk to the model under
test (or a fixture for tests),
- a :class:`TurnDecider` that plays the user (LLM-backed in prod;
scripted / heuristic for tests),
- a system prompt rendered once at session start.
The result of ``run(project)`` is a list of :class:`TurnRecord`,
chained via parent_step_id → step_id. Persist them in order; the
full session reconstructs by sorting on ``turn_index`` within a
``session_id``.
"""
def __init__(
self,
*,
agent: AgentClient,
decider: TurnDecider,
max_turns: int = 10,
system_prompt: str | None = None,
):
if max_turns < 1:
raise ValueError("max_turns must be >= 1")
self._agent = agent
self._decider = decider
self._max_turns = max_turns
self._system_prompt = system_prompt
def _render_system(self, project: Project) -> str:
if self._system_prompt is not None:
return self._system_prompt
return (
"You are an autonomous elizaOS agent. The user is pursuing a "
f"multi-step project: {project.title}. Goal: {project.goal}. "
"Drive each turn forward; ask follow-up questions only when "
"needed."
)
def run(self, project: Project) -> list[TurnRecord]:
"""Execute one project session and return its turn records.
Always emits at least one record (the seeded turn-0). The final
record's ``termination`` field carries the reason; intermediate
records have ``termination=None``.
"""
session_id = _new_session_id(project.id)
system_msg = Message(role="system", content=self._render_system(project))
history: list[Message] = [system_msg]
records: list[TurnRecord] = []
parent_step_id: str | None = None
# turn 0 uses the project's initial user text
next_user_text: str | None = project.initial_user_text
for turn_index in range(self._max_turns):
if next_user_text is None:
# decider chose to terminate before emitting a turn
if records:
final = records[-1]
final.termination = {"reason": TerminationReason.GOAL_REACHED}
break
user_msg = Message(role="user", content=next_user_text)
history.append(user_msg)
agent_text = self._agent.respond(project=project, history=list(history))
agent_msg = Message(role="assistant", content=agent_text)
history.append(agent_msg)
step_id = _new_step_id()
record = TurnRecord(
synth_kind=SYNTH_KIND,
session_id=session_id,
project={
"id": project.id,
"title": project.title,
"goal": project.goal,
"metadata": dict(project.metadata),
},
turn_index=turn_index,
parent_step_id=parent_step_id,
step_id=step_id,
messages=[
{"role": "system", "content": system_msg.content},
{"role": "user", "content": next_user_text},
{"role": "assistant", "content": agent_text},
],
user_text=next_user_text,
agent_text=agent_text,
termination=None,
)
records.append(record)
parent_step_id = step_id
decision = self._decider.decide(
project=project,
history=list(history),
turn_index=turn_index + 1,
max_turns=self._max_turns,
)
if decision.terminate:
record.termination = {
"reason": decision.reason or TerminationReason.MAX_TURNS,
}
break
next_user_text = decision.next_user_text
else:
# for-else: we exhausted max_turns without a termination decision
if records:
records[-1].termination = {"reason": TerminationReason.MAX_TURNS}
return records
# ─────────────────────────── JSONL writer ────────────────────────────
def write_records(records: Iterable[TurnRecord], out_path: Path) -> int:
"""Append turn records to a JSONL file. Returns count written."""
out_path.parent.mkdir(parents=True, exist_ok=True)
n = 0
with out_path.open("a", encoding="utf-8") as f:
for r in records:
f.write(json.dumps(r.to_dict(), ensure_ascii=False) + "\n")
n += 1
return n
def iter_sessions(
projects: list[Project],
*,
simulator: ProjectSimulator,
) -> Iterator[list[TurnRecord]]:
"""Yield one list-of-records per project. Generator so callers can stream-write."""
for project in projects:
yield simulator.run(project)
# ─────────────────────────── CLI (thin wrapper) ────────────────────────────
def _build_logger() -> logging.Logger:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
return logging.getLogger("project_simulator")
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
ap = argparse.ArgumentParser(
prog="project_simulator",
description="Generate multi-turn project trajectories for synth.",
)
ap.add_argument(
"--seed-file",
type=Path,
required=True,
help="JSONL of seed scenarios (one project per line; "
"see drive_eliza.py for the scenario shape).",
)
ap.add_argument(
"--turns",
type=int,
default=10,
help="Max turns per project session.",
)
ap.add_argument(
"--output-dir",
type=Path,
required=True,
help="Directory where multi_turn_project.jsonl is written.",
)
ap.add_argument(
"--num-projects",
type=int,
default=0,
help="How many projects to author from the seed (0 = all).",
)
ap.add_argument(
"--seed",
type=int,
default=0,
help="Random seed for project sampling.",
)
ap.add_argument(
"--decider",
choices=["heuristic"],
default="heuristic",
help="Turn-decider implementation. Only 'heuristic' is wired into "
"the CLI today; LLM-backed deciders are invoked programmatically.",
)
return ap.parse_args(argv)
def _default_agent_client() -> AgentClient:
"""The CLI default uses a fixture echo client. Production callers
construct their own AgentClient (HTTP / Together / Anthropic) and
drive ``ProjectSimulator`` directly — see ``together_synth.py`` for
the pattern. The CLI keeps no business logic."""
return EchoAgentClient()
def _default_decider(kind: str) -> TurnDecider:
if kind == "heuristic":
return HeuristicTurnDecider()
raise ValueError(f"unknown decider kind: {kind}")
def main(argv: list[str] | None = None) -> int:
log = _build_logger()
args = _parse_args(argv)
if not args.seed_file.exists():
log.error("seed file not found: %s", args.seed_file)
return 2
seed_records = load_seed_records(args.seed_file)
if not seed_records:
log.error("seed file is empty: %s", args.seed_file)
return 2
rng = random.Random(args.seed)
author = SeedFileProjectAuthor(seed_records)
n = args.num_projects or len(seed_records)
projects = author.author(n=n, rng=rng)
log.info("authored %d projects from %s", len(projects), args.seed_file)
simulator = ProjectSimulator(
agent=_default_agent_client(),
decider=_default_decider(args.decider),
max_turns=args.turns,
)
out_path = args.output_dir / "multi_turn_project.jsonl"
total = 0
for records in iter_sessions(projects, simulator=simulator):
total += write_records(records, out_path)
log.info("wrote %d turn records → %s", total, out_path)
return 0
if __name__ == "__main__":
sys.exit(main())