diff --git a/docs/persona-finetuning-dataset-exploration.md b/docs/persona-finetuning-dataset-exploration.md index 4bc683e..ed67b40 100644 --- a/docs/persona-finetuning-dataset-exploration.md +++ b/docs/persona-finetuning-dataset-exploration.md @@ -193,17 +193,19 @@ Use the swarm explorer to chase high-loss moments instead of uniform random examples: ```powershell -node scripts/run-metame-swarm-explorer.mjs --workers 2 --runs-per-worker 4 --seed metame-scout -node scripts/run-metame-swarm-explorer.mjs --real --workers 2 --runs-per-worker 4 --seed metame-scout +node scripts/run-metame-swarm-explorer.mjs --agents 4 --seed metame-scout +node scripts/run-metame-swarm-explorer.mjs --real --agents 4 --seed metame-scout ``` The default mode is a dry run. Real mode runs Codex workers: -1. select one timeline candidate using underexplored strata, feature novelty, - prior loss, and seeded jitter -2. run `workers * runs-per-worker` attempts -3. run an optimizer pass over high-loss outputs -4. run another `workers * runs-per-worker` attempts +1. select one timeline candidate per agent using underexplored strata, feature + novelty, prior loss, and seeded jitter +2. run each Face normally against its assigned scenario +3. run an Interpreter pass that converts the Face output to Weksa interlingua +4. lower that interlingua with Weksa and score against the hidden utterance +5. run an optimizer pass over high-loss outputs +6. rerun the same scenarios once per agent after optimization Artifacts are written under `.weksa-runs/metame-swarm/`, which is ignored by git because run packets may include projected Persona memory and raw Discord diff --git a/scripts/run-metame-swarm-explorer.mjs b/scripts/run-metame-swarm-explorer.mjs index 71295b0..82bd5bb 100644 --- a/scripts/run-metame-swarm-explorer.mjs +++ b/scripts/run-metame-swarm-explorer.mjs @@ -22,8 +22,8 @@ const cultureProfilePaths = [ const options = parseArgs(process.argv.slice(2)); const dryRun = !options.real; -const workers = Number(options.workers ?? 2); -const runsPerWorker = Number(options.runsPerWorker ?? 4); +const agents = Number(options.agents ?? options.workers ?? 4); +const runsPerAgent = Number(options.runsPerWorker ?? 1); const seed = options.seed ?? new Date().toISOString().slice(0, 10); const runId = options.runId ?? `swarm-${new Date().toISOString().replace(/[:.]/g, "-")}`; const outputRoot = resolve(repoRoot, options.outRoot ?? defaultOutputRoot); @@ -35,10 +35,10 @@ const maxCandidates = Number(options.maxCandidates ?? 240); const adjacencyMinutes = Number(options.adjacencyMinutes ?? 60); const candidateMessageId = options.messageId; -if (!Number.isInteger(workers) || workers < 1) { - throw new Error("--workers must be a positive integer."); +if (!Number.isInteger(agents) || agents < 1) { + throw new Error("--agents/--workers must be a positive integer."); } -if (!Number.isInteger(runsPerWorker) || runsPerWorker < 1) { +if (!Number.isInteger(runsPerAgent) || runsPerAgent < 1) { throw new Error("--runs-per-worker must be a positive integer."); } @@ -51,57 +51,78 @@ const candidates = buildCandidates(archive.messages, { beforeMs, adjacencyMs: adjacencyMinutes * 60_000, }); -const candidate = candidateMessageId - ? mustFindCandidate(candidates, candidateMessageId) - : chooseCandidate(candidates, ledger, { seed, maxCandidates }); +const selectedCandidates = candidateMessageId + ? [mustFindCandidate(candidates, candidateMessageId)] + : chooseCandidateSet(candidates, ledger, { seed, maxCandidates, count: agents }); -const culturePack = await loadCulturePack(candidate.target.content); +const holdouts = selectedCandidates.map((candidate) => candidate.target.content); +const culturePack = await loadCulturePack(holdouts); const baseMemorySurface = dryRun ? renderDryBaseMemorySurface() : await renderProjectedBaseMemorySurface(runRoot); const promptState = { version: 1, + projectorNotes: [ + "Surface current scenario pressure as lived context, not as schema mechanics.", + ], memoryNotes: [ "Treat the selected Discord moment as current and unresolved.", - "Persona produces interlingua intent only; Weksa owns final surface lowering.", "The target utterance is hidden from generation and appears only in scoring.", ], + faceNotes: [ + "Metame should think normally in her own voice before any interlingua exists.", + "A normal Face output may include Private thought and Would say; do not ask the Face to write Weksa packets.", + ], + interpreterNotes: [ + "The Interpreter converts natural Face output into Weksa interlingua intent.", + "Preserve speech act, social move, stance, referents, and constraints; do not copy the final surface as a target.", + ], loweringNotes: [ "Preserve Persona-produced social intent over generic assistant helpfulness.", "Use prior context as evidence for selection, not as material to quote back unless explanation is the speech act.", "When interlingua preserves a salient keyword or identity label, do not replace it with a new joke unless the culture stack asks for that.", ], + cultureNotes: [ + "Missing behavior should become candidate culture/subculture ontology only when it generalizes beyond one answer.", + ], + personaStateAdjustmentNotes: [ + "Temporary run memory may be adjusted to make the scenario current; canonical Persona state is not mutated by the swarm.", + ], }; -await writeJson(resolve(runRoot, "candidate.json"), redactedCandidateRecord(candidate)); -await writeFile(resolve(runRoot, "conversation-before.md"), renderConversationBefore(candidate), "utf8"); -await writeFile(resolve(runRoot, "target.answer.txt"), `${candidate.target.content.trim()}\n`, "utf8"); +await writeJson(resolve(runRoot, "candidates.json"), selectedCandidates.map(redactedCandidateRecord)); +await Promise.all(selectedCandidates.map(async (candidate, index) => { + const candidateRoot = resolve(runRoot, "candidates", `${index}-${candidate.id}`); + await mkdir(candidateRoot, { recursive: true }); + await writeFile(resolve(candidateRoot, "conversation-before.md"), renderConversationBefore(candidate), "utf8"); + await writeFile(resolve(candidateRoot, "target.answer.txt"), `${candidate.target.content.trim()}\n`, "utf8"); +})); await writeJson(resolve(runRoot, "initial-prompt-state.json"), promptState); const phase1 = await runPhase({ phase: "phase1", - candidate, + candidates: selectedCandidates, promptState, baseMemorySurface, culturePack, - workers, - runsPerWorker, + agents, + runsPerAgent, dryRun, }); const optimizedPromptState = dryRun ? renderDryOptimization(promptState, phase1) - : await runOptimizationPass({ candidate, promptState, phaseSummary: phase1, culturePack }); + : await runOptimizationPass({ candidates: selectedCandidates, promptState, phaseSummary: phase1, culturePack }); await writeJson(resolve(runRoot, "optimized-prompt-state.json"), optimizedPromptState); const phase2 = await runPhase({ phase: "phase2", - candidate, + candidates: selectedCandidates, promptState: optimizedPromptState, baseMemorySurface, culturePack, - workers, - runsPerWorker, + agents, + runsPerAgent, dryRun, }); @@ -110,9 +131,9 @@ const summary = { run_id: runId, mode: dryRun ? "dry_run" : "real", seed, - workers, - runs_per_worker: runsPerWorker, - candidate: redactedCandidateRecord(candidate), + agents, + runs_per_agent: runsPerAgent, + candidates: selectedCandidates.map(redactedCandidateRecord), phase1: summarizePhase(phase1), phase2: summarizePhase(phase2), high_loss_signal: pickHighLoss([...phase1.runs, ...phase2.runs]), @@ -126,17 +147,20 @@ async function runPhase(input) { const phaseRoot = resolve(runRoot, input.phase); await mkdir(phaseRoot, { recursive: true }); const jobs = []; - for (let worker = 0; worker < input.workers; worker += 1) { - for (let index = 0; index < input.runsPerWorker; index += 1) { + for (let agent = 0; agent < input.agents; agent += 1) { + const candidate = input.candidates[agent % input.candidates.length]; + for (let index = 0; index < input.runsPerAgent; index += 1) { jobs.push({ - worker, + agent, + worker: agent, attempt: index, - id: `${input.phase}-w${worker}-r${index}`, + candidate, + id: `${input.phase}-a${agent}-r${index}-${candidate.id}`, }); } } - const runs = await runPool(jobs, input.workers, async (job) => { + const runs = await runPool(jobs, input.agents, async (job) => { const attemptRoot = resolve(phaseRoot, job.id); await mkdir(attemptRoot, { recursive: true }); return runAttempt({ @@ -156,8 +180,9 @@ async function runPhase(input) { } async function runAttempt(input) { - const memorySurface = renderMemorySurface(input.baseMemorySurface, input.candidate, input.promptState, input.job); - const conversationSurface = renderConversationSurface(input.candidate); + const candidate = input.job.candidate; + const memorySurface = renderMemorySurface(input.baseMemorySurface, candidate, input.promptState, input.job); + const conversationSurface = renderConversationSurface(candidate); await writeFile(resolve(input.attemptRoot, "memory-surface.md"), memorySurface, "utf8"); await writeFile(resolve(input.attemptRoot, "conversation-surface.md"), conversationSurface, "utf8"); @@ -170,31 +195,46 @@ async function runAttempt(input) { }); await writeFile(resolve(input.attemptRoot, "voidbot-assembled-prompt.md"), assembledPrompt, "utf8"); - const interlinguaRequest = renderPersonaInterlinguaRequest({ + const faceTurnRequest = renderFaceTurnRequest({ assembledPrompt, - candidate: input.candidate, + candidate, promptState: input.promptState, }); - await writeFile(resolve(input.attemptRoot, "persona-interlingua-request.md"), interlinguaRequest, "utf8"); - const interlingua = input.dryRun - ? renderDryInterlingua(input.candidate) - : await runCodex(interlinguaRequest, { - job: `${input.job.id}:persona-interlingua`, + await writeFile(resolve(input.attemptRoot, "face-turn-request.md"), faceTurnRequest, "utf8"); + const faceTurn = input.dryRun + ? renderDryFaceTurn(candidate) + : await runCodex(faceTurnRequest, { + job: `${input.job.id}:face-turn`, cwd: repoRoot, logRoot: input.attemptRoot, }); - await writeFile(resolve(input.attemptRoot, "persona-interlingua.yaml"), `${interlingua.trim()}\n`, "utf8"); + await writeFile(resolve(input.attemptRoot, "face-turn-output.md"), `${faceTurn.trim()}\n`, "utf8"); + + const interlinguaRequest = renderInterpreterInterlinguaRequest({ + faceTurn, + candidate, + promptState: input.promptState, + }); + await writeFile(resolve(input.attemptRoot, "interpreter-interlingua-request.md"), interlinguaRequest, "utf8"); + const interlingua = input.dryRun + ? renderDryInterlingua(candidate) + : await runCodex(interlinguaRequest, { + job: `${input.job.id}:interpreter-interlingua`, + cwd: repoRoot, + logRoot: input.attemptRoot, + }); + await writeFile(resolve(input.attemptRoot, "interpreter-interlingua.yaml"), `${interlingua.trim()}\n`, "utf8"); const loweringRequest = renderWeksaLoweringRequest({ memorySurface, culturePack: input.culturePack, interlingua, promptState: input.promptState, - holdout: input.candidate.target.content, + holdouts: input.candidates.map((entry) => entry.target.content), }); await writeFile(resolve(input.attemptRoot, "weksa-lowering-request.md"), loweringRequest, "utf8"); const lowered = input.dryRun - ? renderDryLowering(input.candidate, input.job) + ? renderDryLowering(candidate, input.job) : await runCodex(loweringRequest, { job: `${input.job.id}:weksa-lowering`, cwd: repoRoot, @@ -203,13 +243,15 @@ async function runAttempt(input) { await writeFile(resolve(input.attemptRoot, "weksa-lowered-output.yaml"), `${lowered.trim()}\n`, "utf8"); const spokenText = extractSpokenText(lowered); - const score = scoreText(spokenText, input.candidate.target.content); + const score = scoreText(spokenText, candidate.target.content); const run = { id: input.job.id, + agent: input.job.agent, worker: input.job.worker, attempt: input.job.attempt, + candidate: redactedCandidateRecord(candidate), spoken_text: spokenText, - target_text: input.candidate.target.content, + target_text: candidate.target.content, score, loss: Number((1 - score.f1).toFixed(3)), }; @@ -217,7 +259,7 @@ async function runAttempt(input) { return run; } -async function runOptimizationPass({ candidate, promptState, phaseSummary, culturePack }) { +async function runOptimizationPass({ candidates, promptState, phaseSummary, culturePack }) { const optimizerRoot = resolve(runRoot, "optimizer"); await mkdir(optimizerRoot, { recursive: true }); const highLoss = pickHighLoss(phaseSummary.runs); @@ -229,14 +271,19 @@ async function runOptimizationPass({ candidate, promptState, phaseSummary, cultu "Return JSON only with this shape:", "{", " \"version\": 2,", + " \"projectorNotes\": [\"...\"],", " \"memoryNotes\": [\"...\"],", + " \"faceNotes\": [\"...\"],", + " \"interpreterNotes\": [\"...\"],", " \"loweringNotes\": [\"...\"],", + " \"cultureNotes\": [\"...\"],", + " \"personaStateAdjustmentNotes\": [\"...\"],", " \"hypotheses\": [\"...\"],", " \"doNotDo\": [\"...\"]", "}", "", "Candidate metadata:", - JSON.stringify(redactedCandidateRecord(candidate), null, 2), + JSON.stringify(candidates.map(redactedCandidateRecord), null, 2), "", "Target answer is withheld from generation. For optimizer scoring only, normalized loss summary:", JSON.stringify(highLoss, null, 2), @@ -262,14 +309,18 @@ async function runOptimizationPass({ candidate, promptState, phaseSummary, cultu }); } -function renderPersonaInterlinguaRequest({ assembledPrompt, candidate, promptState }) { +function renderFaceTurnRequest({ assembledPrompt, candidate, promptState }) { return [ - "# Persona Interlingua Worker", + "# Metame Face Worker", "", - "You are Metame for one private VoidBot Persona turn, but you are not writing the final Discord message.", - "Emit Weksa interlingua intent only. The answer-key utterance is hidden.", + "Be Metame for one private VoidBot Persona turn. Think normally as the Face.", + "Do not produce Weksa interlingua. Do not see or infer the held-out target utterance.", "", "Temporary optimization notes:", + ...promptState.faceNotes.map((note) => `- ${note}`), + "", + "Temporary memory/projector notes:", + ...promptState.projectorNotes.map((note) => `- ${note}`), ...promptState.memoryNotes.map((note) => `- ${note}`), "", "VoidBot assembled prompt:", @@ -282,14 +333,39 @@ function renderPersonaInterlinguaRequest({ assembledPrompt, candidate, promptSta renderCandidateScene(candidate), "```", "", + "Return natural Face output using the familiar shape when useful: Private thought, Would say, What should stick.", + ].join("\n"); +} + +function renderInterpreterInterlinguaRequest({ faceTurn, candidate, promptState }) { + return [ + "# Face Interpreter To Weksa Interlingua", + "", + "You are the Interpreter between natural Metame Face output and Weksa.", + "VoidBot's current Interpreter is mostly passthrough; this harness tests the missing authority boundary.", + "Convert the Face's normal thought/speech intent into Weksa interlingua. Do not lower to final English.", + "", + "Temporary interpreter notes:", + ...promptState.interpreterNotes.map((note) => `- ${note}`), + "", + "Current candidate moment, target withheld:", + "```yaml", + renderCandidateScene(candidate), + "```", + "", + "Natural Face output:", + "```markdown", + faceTurn, + "```", + "", "Return one YAML interlingua packet.", ].join("\n"); } -function renderWeksaLoweringRequest({ memorySurface, culturePack, interlingua, promptState, holdout }) { +function renderWeksaLoweringRequest({ memorySurface, culturePack, interlingua, promptState, holdouts }) { const redactedProfiles = culturePack.profiles.map((profile) => ({ ...profile, - text: redactHoldout(profile.text, holdout), + text: redactHoldouts(profile.text, holdouts), })); return [ "# Weksa Lowering Worker", @@ -300,6 +376,9 @@ function renderWeksaLoweringRequest({ memorySurface, culturePack, interlingua, p "Temporary optimization notes:", ...promptState.loweringNotes.map((note) => `- ${note}`), "", + "Temporary culture ontology notes:", + ...promptState.cultureNotes.map((note) => `- ${note}`), + "", "Projected Metame memory/context surface:", "```markdown", memorySurface, @@ -321,7 +400,7 @@ function renderWeksaLoweringRequest({ memorySurface, culturePack, interlingua, p "", "Target-language surface overlay:", "```yaml", - redactHoldout(culturePack.targetOverlay, holdout), + redactHoldouts(culturePack.targetOverlay, holdouts), "```", "", "Persona-produced interlingua:", @@ -411,6 +490,35 @@ function chooseCandidate(candidates, ledger, { seed, maxCandidates }) { return pool[0].candidate; } +function chooseCandidateSet(candidates, ledger, { seed, maxCandidates, count }) { + const selected = []; + const selectedIds = new Set(); + for (let index = 0; index < count; index += 1) { + const remaining = candidates.filter((candidate) => !selectedIds.has(candidate.id)); + if (remaining.length === 0) { + break; + } + const candidate = chooseCandidate(remaining, ledger, { + seed: `${seed}:agent:${index}`, + maxCandidates, + }); + selected.push(candidate); + selectedIds.add(candidate.id); + const stratum = candidateStratum(candidate); + const prior = ledger.strata[stratum] ?? { count: 0, totalLoss: 0, avgLoss: 0.5 }; + ledger.strata[stratum] = { + ...prior, + count: prior.count + 1, + totalLoss: prior.totalLoss ?? prior.avgLoss * prior.count, + avgLoss: prior.avgLoss, + }; + for (const feature of candidate.features) { + ledger.features[feature] = (ledger.features[feature] ?? 0) + 1; + } + } + return selected; +} + function mustFindCandidate(candidates, messageId) { const candidate = candidates.find((entry) => entry.id === messageId); if (!candidate) { @@ -441,6 +549,8 @@ function renderMemorySurface(baseMemorySurface, candidate, promptState, job) { `- Treat channel ${candidate.channelName || candidate.channelId} in ${candidate.year} as the live room.`, `- The hidden target is a ${candidate.lengthClass} Metacrat message after the visible context.`, `- Candidate features: ${candidate.features.join(", ") || "none"}.`, + ...promptState.projectorNotes.map((note) => `- Projector pressure: ${note}`), + ...promptState.personaStateAdjustmentNotes.map((note) => `- Temporary Persona-state adjustment: ${note}`), ...promptState.memoryNotes.map((note) => `- ${note}`), ].join("\n"); } @@ -533,6 +643,14 @@ function renderDryAssembledPrompt(memorySurface, conversationSurface) { ].join("\n"); } +function renderDryFaceTurn(candidate) { + return [ + "Private thought: This is a dry-run reconstruction of what the visible room seems to be asking for.", + `Would say: ${candidate.target.content}`, + "What should stick: The Interpreter should derive intent from the Face turn, not from a direct Weksa packet.", + ].join("\n"); +} + function renderDryInterlingua(candidate) { return [ "interlingua_version: weksa.interlingua.v0", @@ -573,14 +691,31 @@ function renderDryOptimization(promptState, phase) { const worst = pickHighLoss(phase.runs).slice(0, 3); return normalizePromptState({ version: promptState.version + 1, + projectorNotes: [ + ...promptState.projectorNotes, + ], memoryNotes: [ ...promptState.memoryNotes, "Dry optimizer: preserve whether the target is reply-to-other or self-continuation.", ], + faceNotes: [ + ...promptState.faceNotes, + "Dry optimizer: keep Face thought natural; do not emit interlingua from the Face.", + ], + interpreterNotes: [ + ...promptState.interpreterNotes, + "Dry optimizer: keep length/register constraints visible when deriving interlingua.", + ], loweringNotes: [ ...promptState.loweringNotes, "Dry optimizer: keep output length class close to the hidden target metadata.", ], + cultureNotes: [ + ...promptState.cultureNotes, + ], + personaStateAdjustmentNotes: [ + ...promptState.personaStateAdjustmentNotes, + ], hypotheses: worst.map((run) => `High loss on ${run.id} suggests missing local register or length pressure.`), doNotDo: [ "Do not copy answer-key text into culture profiles.", @@ -588,7 +723,7 @@ function renderDryOptimization(promptState, phase) { }); } -async function loadCulturePack(holdout) { +async function loadCulturePack(holdouts) { const stack = await readFile(resolve(fixtureRoot, "metame-cultural-stack.yaml"), "utf8"); const profiles = await Promise.all(cultureProfilePaths.map(async (path) => ({ path, @@ -599,7 +734,7 @@ async function loadCulturePack(holdout) { "utf8", ); return { - stack: redactHoldout(stack, holdout), + stack: redactHoldouts(stack, holdouts), profiles, targetOverlay, profileIds: [ @@ -718,19 +853,23 @@ async function loadPriorLedger(outputRootPath) { const strata = {}; const features = {}; for (const summary of summaries) { - const candidate = summary.candidate; - if (!candidate) { + const candidates = Array.isArray(summary.candidates) + ? summary.candidates + : summary.candidate ? [summary.candidate] : []; + if (candidates.length === 0) { continue; } - const stratum = `${candidate.channel_name || candidate.channel_id}:${candidate.length_class}`; const loss = Number(summary.high_loss_signal?.[0]?.loss ?? summary.phase2?.avg_loss ?? summary.phase1?.avg_loss ?? 0.5); - const current = strata[stratum] ?? { count: 0, totalLoss: 0, avgLoss: 0.5 }; - current.count += 1; - current.totalLoss += loss; - current.avgLoss = current.totalLoss / current.count; - strata[stratum] = current; - for (const feature of candidate.features ?? []) { - features[feature] = (features[feature] ?? 0) + 1; + for (const candidate of candidates) { + const stratum = `${candidate.channel_name || candidate.channel_id}:${candidate.length_class}`; + const current = strata[stratum] ?? { count: 0, totalLoss: 0, avgLoss: 0.5 }; + current.count += 1; + current.totalLoss += loss; + current.avgLoss = current.totalLoss / current.count; + strata[stratum] = current; + for (const feature of candidate.features ?? []) { + features[feature] = (features[feature] ?? 0) + 1; + } } } return { strata, features }; @@ -739,16 +878,16 @@ async function loadPriorLedger(outputRootPath) { async function appendLedger(outputRootPath, summary) { await mkdir(outputRootPath, { recursive: true }); const ledgerPath = resolve(outputRootPath, "ledger.jsonl"); - const line = `${JSON.stringify({ + const lines = summary.candidates.map((candidate) => JSON.stringify({ run_id: summary.run_id, - candidate_id: summary.candidate.id, - channel_name: summary.candidate.channel_name, - length_class: summary.candidate.length_class, + candidate_id: candidate.id, + channel_name: candidate.channel_name, + length_class: candidate.length_class, phase1_avg_loss: summary.phase1.avg_loss, phase2_avg_loss: summary.phase2.avg_loss, high_loss: summary.high_loss_signal?.[0]?.loss, - })}\n`; - await writeFile(ledgerPath, line, { flag: "a" }); + })).join("\n"); + await writeFile(ledgerPath, `${lines}\n`, { flag: "a" }); } function summarizePhase(phase) { @@ -775,7 +914,9 @@ function pickHighLoss(runs) { function compactRun(run) { return { id: run.id, + candidate_id: run.candidate?.id, worker: run.worker, + agent: run.agent, attempt: run.attempt, spoken_text: run.spoken_text, token_f1: run.score.f1, @@ -945,8 +1086,13 @@ function extractJsonObject(text) { function normalizePromptState(state) { return { version: Number(state.version ?? 1), + projectorNotes: normalizeStringArray(state.projectorNotes).slice(0, 12), memoryNotes: normalizeStringArray(state.memoryNotes).slice(0, 12), + faceNotes: normalizeStringArray(state.faceNotes).slice(0, 12), + interpreterNotes: normalizeStringArray(state.interpreterNotes).slice(0, 12), loweringNotes: normalizeStringArray(state.loweringNotes).slice(0, 16), + cultureNotes: normalizeStringArray(state.cultureNotes).slice(0, 12), + personaStateAdjustmentNotes: normalizeStringArray(state.personaStateAdjustmentNotes).slice(0, 12), hypotheses: normalizeStringArray(state.hypotheses).slice(0, 12), doNotDo: normalizeStringArray(state.doNotDo).slice(0, 12), }; @@ -971,6 +1117,10 @@ function redactHoldout(text, holdout) { return redacted; } +function redactHoldouts(text, holdouts) { + return holdouts.reduce((current, holdout) => redactHoldout(current, holdout), text); +} + function readProfileId(text) { const match = /^profile_id:\s*(.+)$/m.exec(text); return match ? match[1].trim() : undefined; @@ -1055,6 +1205,10 @@ function parseArgs(args) { parsed.workers = args[index + 1]; index += 1; break; + case "--agents": + parsed.agents = args[index + 1]; + index += 1; + break; case "--runs-per-worker": parsed.runsPerWorker = args[index + 1]; index += 1;