use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::path::Path;
use serde_json::Value;
use argonauts_core::TaskId;
use argonauts_core::events::RunEventKind;
use argonauts_core::events::approval::{
AnswerAction, ApprovalDecided, ApprovalKind, ApprovalOpened, ApprovalPacket,
ChangeRequestPacket, FreeText,
};
use argonauts_core::events::goal::Criterion;
use argonauts_core::events::run::{
CheckedInstruction, InstructionOutcome, Party, RunLifecycle, RunMode, RunNamed,
};
use argonauts_core::events::session::{EndReason, InputPart, InputPartKind};
use argonauts_core::events::workflow::{
DeclaredPhase, RunNote, WorkflowChanged, WorkflowDeclared, WorkflowEvent,
};
use argonauts_core::log::{Note, NoteLevel};
use argonauts_core::names::{ApprovalId, ModelRef, RoleName, SessionKey, WorkflowId};
use argonauts_core::policy::{EvidenceDocument, EvidenceFacts};
use argonauts_core::settings::workflows::{OrchestratorDesign, RunStyle};
use argonauts_core::steps::StepError;
use argonauts_core::steps::design::{
DesignFix, DesignInput, DesignOutput, DesignWorkflow, FixDesign, WorkflowExample,
};
use argonauts_core::steps::intake::InstructionKind;
use argonauts_core::workflow::{
StandardPhase, StepKind, StepRef, Workflow, WorkflowParams, WorkflowRef,
};
use argonauts_core::workflow_files;
use crate::approvals::{self, ApprovalRequest};
use crate::engine::host::{Answered, Ask, Host};
use crate::error::{EngineError, EngineResult};
use crate::prompts::workflow::{DESIGN_WORKFLOW, FIX_DESIGN};
use crate::state::RunState;
use crate::workflow::brief::Problem;
use crate::workflow::validate::kind_name;
use crate::workflow::{StepAt, brief, builtin, params, runner, shape, sizing, yaml};
const PROPOSED: &str = "Proposed workflow";
pub async fn propose(
host: &Host,
target: Option<WorkflowId>,
workflow: Workflow,
reason: String,
) -> Result<ApprovalId, String> {
let id = target
.or_else(|| host.read(|state| state.workflows.root.clone()))
.ok_or_else(|| "no workflow is running".to_owned())?;
let request = change_request(host, &id, &workflow, &reason, Party::User, None)?;
let opening = approvals::open(host, request).map_err(|err| err.to_string())?;
Ok(opening.id().clone())
}
fn change_request(
host: &Host,
id: &WorkflowId,
workflow: &Workflow,
reason: &str,
from: Party,
phase_end: Option<&StepAt>,
) -> Result<ApprovalRequest, String> {
let current = valid_change(host, id, workflow)?;
let proposed = yaml::print(workflow)?;
let running = yaml::print(¤t)?;
let task = TaskId::new(id.to_string()).map_err(|err| err.to_string())?;
Ok(ApprovalRequest {
at: phase_end.cloned(),
kind: ApprovalKind::ChangeRequest,
title: change_title(id, phase_end.map(|at| at.step.phase)),
task: None,
packet: ApprovalPacket::ChangeRequest(ChangeRequestPacket {
from,
task,
text: format!(
"Change workflow `{}` from its next phase on: {reason}",
current.name
),
paths: Vec::new(),
why_you: "It changes how the run goes from its next phase on.".to_owned(),
paused: Vec::new(),
}),
facts: EvidenceFacts::default(),
agent_outputs: vec![reason.to_owned()],
documents: vec![
EvidenceDocument {
label: PROPOSED.to_owned(),
text: proposed,
path: None,
sha256: None,
},
EvidenceDocument {
label: "Running workflow".to_owned(),
text: running,
path: None,
sha256: None,
},
],
options: None,
free_text: FreeText::Guidance,
authors: Vec::new(),
})
}
fn valid_change(host: &Host, id: &WorkflowId, workflow: &Workflow) -> Result<Workflow, String> {
let (current, depth, takes_effect) = host
.read(|state| {
let record = state.workflows.by_id.get(id)?;
Some((
record.workflow.clone(),
state.workflows.depth(id),
record.change_from(),
))
})
.ok_or_else(|| format!("no workflow {id} is running"))?;
runner::check(host, workflow, depth).map_err(|errors| {
errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n")
})?;
if *workflow == current {
return Err("the change is the workflow already running".to_owned());
}
keeps_what_ran(¤t, workflow, takes_effect)?;
Ok(current)
}
fn change_title(id: &WorkflowId, phase_end: Option<u32>) -> String {
match phase_end {
Some(phase) => format!("Workflow change · {id} · after phase {}", phase + 1),
None => format!("Workflow change · {id}"),
}
}
fn keep_what_ran(current: &Workflow, mut proposed: Workflow, from: u32) -> Workflow {
let ran = ¤t.phases[..(from as usize).min(current.phases.len())];
let only_understand = !ran.is_empty()
&& ran
.iter()
.all(|phase| phase.standard == StandardPhase::Understand);
let left_out = !ran
.iter()
.any(|kept| proposed.phases.iter().any(|phase| phase.name == kept.name));
if only_understand && left_out {
let mut phases = ran.to_vec();
phases.append(&mut proposed.phases);
proposed.phases = phases;
}
proposed
}
fn keeps_what_ran(current: &Workflow, proposed: &Workflow, from: u32) -> Result<(), String> {
for index in 0..from as usize {
let Some(kept) = current.phases.get(index) else {
break;
};
match proposed.phases.get(index) {
Some(phase) if phase == kept => {}
Some(_) => {
return Err(format!(
"it changes phase {} ({}), which already started; a change may only change the phases after it",
index + 1,
kept.name
));
}
None => {
return Err(format!(
"it drops phase {} ({}), which already started",
index + 1,
kept.name
));
}
}
}
Ok(())
}
fn apply(
host: &Host,
id: &WorkflowId,
workflow: Workflow,
decided: &ApprovalDecided,
reason: String,
) -> EngineResult<()> {
if decided.action != AnswerAction::Approve {
return Ok(());
}
let Some((current, takes_effect, applied)) = host.read(|state| {
let record = state.workflows.by_id.get(id)?;
Some((
record.workflow.clone(),
record.change_from(),
record.changes.contains(&decided.id),
))
}) else {
return Err(EngineError::Refused(format!(
"no workflow {id} was started"
)));
};
if applied || workflow == current {
return Ok(());
}
keeps_what_ran(¤t, &workflow, takes_effect).map_err(|reason| {
EngineError::Refused(format!(
"the change to workflow {id} you accepted at {} wasn't made, since the run moved on: {reason}",
decided.id
))
})?;
record(host, id, workflow, Some(decided.id.clone()), reason)
}
pub fn record(
host: &Host,
id: &WorkflowId,
workflow: Workflow,
approval: Option<ApprovalId>,
reason: String,
) -> EngineResult<()> {
let Some(current) = host.read(|state| {
state
.workflows
.by_id
.get(id)
.map(|record| record.workflow.clone())
}) else {
return Err(EngineError::Refused(format!(
"no workflow {id} was started"
)));
};
let version = runner::version(&workflow)?;
let declared = WorkflowDeclared::of(id, &workflow);
let added: Vec<DeclaredPhase> = declared
.phases
.iter()
.filter(|phase| !current.phases.iter().any(|old| old.name == phase.name))
.cloned()
.collect();
host.record(
None,
None,
RunEventKind::Workflow(WorkflowEvent::Changed(Box::new(WorkflowChanged {
id: id.clone(),
version,
workflow,
approval,
reason: reason.clone(),
}))),
)?;
host.record(
None,
None,
RunEventKind::Workflow(WorkflowEvent::Declared(Box::new(declared))),
)?;
for phase in added {
host.record(
None,
None,
RunEventKind::Workflow(WorkflowEvent::PhaseAdded {
id: id.clone(),
phase,
why: reason.clone(),
}),
)?;
}
if !reason.trim().is_empty() {
host.record(
None,
None,
RunEventKind::Workflow(WorkflowEvent::Note(RunNote {
text: reason,
workflow: Some(id.clone()),
left: false,
})),
)?;
}
Ok(())
}
pub fn on_decided(host: &Host, opened: &ApprovalOpened, decided: &ApprovalDecided) {
if !approvals::is_workflow_change(opened.kind, opened.task.as_ref())
|| decided.action != AnswerAction::Approve
{
return;
}
if let Err(err) = apply_opened(host, opened, decided)
&& let Err(unrecorded) = note(host, NoteLevel::Warn, err.to_string())
{
tracing::error!(%unrecorded, "a workflow change failed, and so did its note: {err}");
}
}
fn apply_opened(
host: &Host,
opened: &ApprovalOpened,
decided: &ApprovalDecided,
) -> EngineResult<()> {
let ApprovalPacket::ChangeRequest(packet) = &opened.packet else {
return Err(EngineError::Refused(format!(
"approval {} isn't a workflow change",
opened.id
)));
};
let id = WorkflowId::parse(packet.task.as_str()).ok_or_else(|| {
EngineError::Refused(format!(
"approval {} names no workflow: {}",
opened.id, packet.task
))
})?;
let evidence = approvals::evidence_of(host, opened)?;
let proposed = evidence
.documents
.iter()
.find(|document| document.label == PROPOSED)
.ok_or_else(|| {
EngineError::Refused(format!("approval {} holds no proposed workflow", opened.id))
})?;
let workflow = yaml::parse(&proposed.text).map_err(EngineError::Refused)?;
let reason = evidence.agent_outputs.first().cloned().unwrap_or_default();
apply(host, &id, workflow, decided, reason)
}
pub fn apply_decided(host: &Host) -> EngineResult<()> {
let waiting: Vec<(ApprovalOpened, ApprovalDecided)> = host.read(|state| {
let made: BTreeSet<&ApprovalId> = state
.workflows
.by_id
.values()
.flat_map(|record| record.changes.iter())
.collect();
state
.approvals
.by_id
.values()
.filter(|record| {
approvals::is_workflow_change(record.opened.kind, record.opened.task.as_ref())
&& record.withdrawn.is_none()
&& !made.contains(&record.opened.id)
})
.filter_map(|record| Some((record.opened.clone(), record.decided.clone()?)))
.collect()
});
for (opened, decided) in &waiting {
on_decided(host, opened, decided);
}
Ok(())
}
fn note(host: &Host, level: NoteLevel, text: String) -> EngineResult<()> {
host.record(None, None, RunEventKind::Note(Note { level, text }))?;
Ok(())
}
fn note_with_detail(host: &Host, level: NoteLevel, line: &str, detail: &str) -> EngineResult<()> {
let note = Note::with_detail(level, line, detail);
host.record(None, None, RunEventKind::Note(note))?;
Ok(())
}
pub async fn adapt_at_phase_end(host: &Host, id: &WorkflowId, phase: u32) -> EngineResult<()> {
if !host.settings().workflows.adapt_at_phase_ends
|| !host.read(|state| runner::crew_run(state, id) && !crate::script::runs(state, id))
{
return Ok(());
}
let Some((current, progress, finished)) = host.read(|state| {
let record = state.workflows.by_id.get(id)?;
let finished: Vec<u32> = (0..=phase).filter(|p| record.phase_finished(*p)).collect();
let progress = record
.steps
.iter()
.filter(|step| step.end.is_some() && step.step.phase <= phase)
.map(|step| {
let phase_name = record
.workflow
.phases
.get(step.step.phase as usize)
.map_or("", |definition| definition.name.as_str());
format!(
"{phase_name}: {} round {}: {}",
kind_name(step.kind),
step.round,
step.summary.as_deref().unwrap_or("done")
)
})
.collect();
Some((record.workflow.clone(), progress, finished))
}) else {
return Err(EngineError::Refused(format!(
"no workflow {id} was started"
)));
};
let title = change_title(id, Some(phase));
let prior = host.read(|state| {
state
.approvals
.by_id
.values()
.filter(|record| {
record.opened.kind == ApprovalKind::ChangeRequest
&& record.opened.title == title
&& record.withdrawn.is_none()
})
.max_by_key(|record| record.since)
.and_then(|record| record.decided.clone())
});
if prior
.as_ref()
.is_some_and(|decided| decided.action != AnswerAction::Approve)
{
return Ok(());
}
let at = StepAt {
step: StepRef {
workflow: id.clone(),
phase,
path: Vec::new(),
},
round: 1,
};
let mut input = design_input(
host,
Some(yaml::print_marked(¤t, &finished).map_err(EngineError::Refused)?),
progress,
);
let tie_breaker = tie_breaker(host);
let sizes_only = host.settings().orchestrator_designs() == OrchestratorDesign::Size
&& input.as_you_asked.is_empty();
let examples = sizing::part(host).unwrap_or_else(|| examples_part(host));
let names = example_names(host);
let mut refused: Option<SessionKey> = None;
for slot in 0..2 {
let asked = Designing {
slot,
at: Some(at.clone()),
tie_breaker: &tie_breaker,
refused: refused.as_ref(),
input: &input,
examples: &examples,
names: &names,
};
let answered = ask_design(host, asked).await?;
refused = resumable(host, &answered);
let resumed = refused.is_some();
let mut send_back = |plainly: &str, errors: Vec<String>| {
input.errors = errors;
if slot > 0 {
return Ok(());
}
let what = format!("The main agent's change after phase {}", phase + 1);
let line = fixing(&what, plainly, resumed);
let detail = input.errors.join("\n");
say_once(host, Some(&answered.session), &line, &detail)
};
let proposed = match resolve(host, &answered.output) {
Ok(Some((_, mut proposed))) => {
proposed.name.clone_from(¤t.name);
proposed.description.clone_from(¤t.description);
proposed.based_on.clone_from(¤t.based_on);
let from = host.read(|state| {
state
.workflows
.by_id
.get(id)
.map_or(0, |record| record.change_from())
});
let proposed = shape::as_written(host, keep_what_ran(¤t, proposed, from));
if sizes_only && let Err(error) = sizing::keeps(host, ¤t, &proposed) {
send_back("went beyond sizing your workflow", vec![error])?;
continue;
}
proposed
}
Ok(None) => return Ok(()),
Err(error) => {
send_back("wrote a workflow code couldn't read", vec![error])?;
continue;
}
};
if proposed == current {
return Ok(());
}
let why = answered.output.why.clone();
if prior.is_none() && host.read(|state| before_go_ahead(state, id)) {
if let Err(errors) = valid_change(host, id, &proposed) {
let errors = errors.lines().map(str::to_owned).collect();
send_back(BROKE, errors)?;
continue;
}
let note = match why.trim() {
"" => "The main agent changed the workflow before anything was written".to_owned(),
why => format!("The main agent changed the workflow: {why}"),
};
return record(host, id, proposed, None, note);
}
let decided = match &prior {
Some(decided) => decided.clone(),
None => {
let from = Party::Agent {
session: answered.session.clone(),
worker: answered.worker.clone(),
};
let request = match change_request(host, id, &proposed, &why, from, Some(&at)) {
Ok(request) => request,
Err(errors) => {
let errors = errors.lines().map(str::to_owned).collect();
send_back(BROKE, errors)?;
continue;
}
};
let opening = if approvals::keeps_yours(
host,
ApprovalKind::ChangeRequest,
&[approvals::CHANGE_TOPIC],
) {
let because = approvals::yours_because("workflow changes");
approvals::open_yours(host, request, because)?
} else {
approvals::open(host, request)?
};
let approval = opening.id().clone();
match opening.decided(host).await {
Ok(decided) => decided,
Err(EngineError::Refused(reason)) if withdrawn(host, &approval) => {
return note(
host,
NoteLevel::Info,
format!(
"The main agent's change to workflow {id} was withdrawn, so it goes on unchanged: {reason}"
),
);
}
Err(err) => return Err(err),
}
}
};
return match apply(host, id, proposed, &decided, why) {
Err(EngineError::Refused(reason)) => note(host, NoteLevel::Warn, reason),
other => other,
};
}
let line = format!(
"The main agent's change after phase {} broke the rules twice, so the workflow goes on \
unchanged.",
phase + 1
);
note_with_detail(host, NoteLevel::Warn, &line, &input.errors.join("\n"))
}
const BROKE: &str = "wrote a workflow that breaks its rules";
fn before_go_ahead(state: &RunState, id: &WorkflowId) -> bool {
fn approval(steps: &[argonauts_core::workflow::Step], kind: ApprovalKind) -> bool {
steps.iter().any(|step| {
(step.kind == StepKind::Approval && step.approval == Some(kind))
|| approval(&step.steps, kind)
})
}
let Some(record) = state.workflows.by_id.get(id) else {
return false;
};
let from = record.change_from() as usize;
let goal_to_come = state.goal.approved.is_none();
state.tasks.by_id.is_empty()
&& record.workflow.phases.iter().skip(from).any(|phase| {
approval(&phase.steps, ApprovalKind::Plan)
|| (goal_to_come && approval(&phase.steps, ApprovalKind::Understand))
})
}
fn withdrawn(host: &Host, approval: &ApprovalId) -> bool {
host.read(|state| {
state
.approvals
.by_id
.get(approval)
.is_some_and(|record| record.withdrawn.is_some())
})
}
pub async fn design_for_task(host: &Host) -> EngineResult<Option<(Option<WorkflowRef>, Workflow)>> {
let settings = host.settings();
let captain = host.read(runner::run_mode) == Some(RunMode::Captain);
if settings.orchestrator_designs() == OrchestratorDesign::Off || captain {
return Ok(None);
}
let mut input = design_input(host, None, Vec::new());
let tie_breaker = tie_breaker(host);
let examples = sizing::part(host).unwrap_or_else(|| examples_part(host));
let names = example_names(host);
let mut refused: Option<SessionKey> = None;
let mut refusals = 0;
for slot in 0..2 {
let asked = Designing {
slot,
at: None,
tie_breaker: &tie_breaker,
refused: refused.as_ref(),
input: &input,
examples: &examples,
names: &names,
};
let answered = match ask_design(host, asked).await {
Ok(answered) => answered,
Err(EngineError::Interrupted) => return Err(EngineError::Interrupted),
Err(err) if slot == 0 && ended_early(&err) => {
let line = "The main agent's session stopped before its brief was done, so a new \
session is writing it.";
say_once(host, None, line, &err.to_string())?;
continue;
}
Err(err) => {
let (what, detail) = match &err {
EngineError::Step(StepError::SchemaMismatch { errors, .. }) => (
"The main agent's design came back incomplete",
format!("output didn't match the schema:\n{}", errors.join("\n")),
),
other => (
"The main agent couldn't design the workflow",
other.to_string(),
),
};
let line = format!(
"{what}, so {} runs as written.",
runner::phases_in_use(host)
);
note_with_detail(host, NoteLevel::Warn, &line, &detail)?;
return Ok(None);
}
};
let brief = &answered.output;
let problems = match accept(host, brief, &names, &input) {
Ok(Some((sizing, workflow))) => {
if let Some(line) = sizing::line(&sizing.changes, &brief.why) {
note(host, NoteLevel::Info, line)?;
}
name_run(host, &workflow, &brief.done_when)?;
return Ok(Some((sizing.from, workflow)));
}
Ok(None) => return Ok(None),
Err(problems) => problems,
};
refusals += 1;
input.errors = problems
.iter()
.map(|problem| problem.line.clone())
.collect();
refused = resumable(host, &answered);
if slot == 0 {
let plainly = problems
.first()
.map_or("broke a rule", |problem| problem.plainly.as_str());
let line = fixing("The main agent's brief", plainly, refused.is_some());
let about = Some(&answered.session);
say_once(host, about, &line, &input.errors.join("\n"))?;
}
}
let line = if refusals > 1 {
format!(
"The main agent's workflow broke the rules twice, so {} runs as written.",
runner::phases_in_use(host)
)
} else {
format!(
"The main agent's workflow broke the rules, so {} runs as written.",
runner::phases_in_use(host)
)
};
note_with_detail(host, NoteLevel::Warn, &line, &input.errors.join("\n"))?;
Ok(None)
}
fn accept(
host: &Host,
brief: &DesignOutput,
names: &[String],
input: &DesignInput,
) -> Result<Option<(sizing::Sizing, Workflow)>, Vec<Problem>> {
let one = |plainly: &str, line: String| vec![Problem::new(plainly, line)];
let (from, mut workflow) = match resolve(host, brief) {
Ok(Some(resolved)) => resolved,
Ok(None) => return Ok(None),
Err(error) => return Err(one("wrote a workflow code couldn't read", error)),
};
if brief.workflow.is_some()
&& brief.based_on.is_none()
&& !host.settings().workflows.allow_composed
{
return Err(one(
"composed a workflow while composing is off",
"composing is off; start from one of the examples".to_owned(),
));
}
let problems = brief::problems(brief, &workflow, names);
if !problems.is_empty() {
return Err(problems);
}
let asked = !input.as_you_asked.is_empty();
let sizing = sizing::check(host, from.as_ref(), &workflow, asked)
.map_err(|error| one("went beyond sizing your workflow", error))?;
brief::apply_identity(&mut workflow, brief);
workflow.name = crate::workflow::naming::unique_name(host, &workflow.name);
let first_try = match &brief.first_try {
Some(plan) if shape::run_style(host) == RunStyle::Goal => {
Some(crate::goal::pursue::first_try(plan).map_err(|error| {
one(
"planned a first try code couldn't read",
format!("`first_try`: {error}"),
)
})?)
}
_ => None,
};
let workflow = shape::for_run(host, workflow, first_try);
runner::check(host, &workflow, 0).map_err(|errors| {
errors
.iter()
.map(|error| Problem::new(BROKE, error.to_string()))
.collect::<Vec<_>>()
})?;
Ok(Some((sizing, workflow)))
}
struct Designing<'a> {
slot: u32,
at: Option<StepAt>,
tie_breaker: &'a ModelRef,
refused: Option<&'a SessionKey>,
input: &'a DesignInput,
examples: &'a InputPart,
names: &'a [String],
}
async fn ask_design(host: &Host, asked: Designing<'_>) -> EngineResult<Answered<DesignOutput>> {
let Designing {
slot,
at,
tie_breaker,
refused,
input,
examples,
names,
} = asked;
let mut answered = match refused {
Some(session) => {
let fix = DesignFix {
errors: input.errors.clone(),
};
host.ask::<FixDesign>(Ask {
slot,
resume: Some(session.clone()),
..Ask::new(
at,
RoleName::TieBreaker,
tie_breaker.clone(),
FIX_DESIGN,
fix,
)
})
.await?
}
None => {
host.ask::<DesignWorkflow>(Ask {
slot,
parts: vec![examples.clone()],
..Ask::new(
at,
RoleName::TieBreaker,
tie_breaker.clone(),
DESIGN_WORKFLOW,
input.clone(),
)
})
.await?
}
};
answered.output.read_based_on(names);
Ok(answered)
}
fn say_once(host: &Host, about: Option<&SessionKey>, line: &str, detail: &str) -> EngineResult<()> {
if host.read(|state| state.said_after(line, about)) {
return Ok(());
}
note_with_detail(host, NoteLevel::Info, line, detail)
}
fn fixing(what: &str, plainly: &str, resumed: bool) -> String {
if resumed {
format!("{what} {plainly}; it's fixing that.")
} else {
format!(
"{what} {plainly}; a new session is fixing that, since the main agent's session can't be \
resumed."
)
}
}
fn resumable(host: &Host, answered: &Answered<DesignOutput>) -> Option<SessionKey> {
let resumes = host
.services()
.workers
.get(&answered.worker)
.is_ok_and(|worker| worker.capabilities().resume);
(resumes && answered.end.cli_session.is_some()).then(|| answered.session.clone())
}
fn ended_early(err: &EngineError) -> bool {
matches!(
err,
EngineError::Step(StepError::Session(end)) if end.reason == EndReason::ContextFull
)
}
pub(crate) fn tie_breaker(host: &Host) -> argonauts_core::names::ModelRef {
host.read(|state| crate::tie_breaker::tie_breaker(state, host.settings()))
.unwrap_or_else(|| host.settings().crew.main.clone())
}
fn design_input(host: &Host, current: Option<String>, progress: Vec<String>) -> DesignInput {
let (request, as_you_asked, goal) = host.read(|state| {
let started = state.run.started.as_ref();
let request = started
.map(|started| started.request.clone())
.unwrap_or_default();
let as_you_asked = started
.map(|started| musts(&started.instructions))
.unwrap_or_default();
let goal = state
.goal
.approved
.as_ref()
.and_then(|goal| serde_json::to_value(goal).ok())
.map(goal_lines)
.unwrap_or_default();
(request, as_you_asked, goal)
});
DesignInput {
request,
goal,
examples: Vec::new(),
step_kinds: STEP_KINDS
.iter()
.map(|(kind, line)| format!("{}: {line}", kind_name(*kind)))
.collect(),
allow_composed: host.settings().workflows.allow_composed,
errors: Vec::new(),
current,
progress,
style: Some(shape::run_style(host)).filter(|style| *style == RunStyle::Goal),
as_you_asked,
}
}
fn musts(instructions: &[CheckedInstruction]) -> Vec<String> {
instructions
.iter()
.filter_map(|instruction| match &instruction.outcome {
InstructionOutcome::Followed { .. }
if instruction.kind == InstructionKind::ForTheMainAgent =>
{
Some(instruction.says.clone())
}
InstructionOutcome::Followed { said, .. } if !said.trim().is_empty() => {
Some(said.clone())
}
_ => None,
})
.collect()
}
fn examples_part(host: &Host) -> InputPart {
let mut text = String::from("## Example workflows\n");
for example in examples(host) {
let record = match (example.runs, example.passed) {
(0, _) => "No similar runs yet.".to_owned(),
(runs, passed) => format!("Passed {passed} of {runs} similar runs."),
};
let _ = write!(
text,
"\n### {}\n\n{}\n{record}\n\n```yaml\n{}\n```\n",
example.name,
example.description.trim(),
example.yaml.trim_end()
);
}
InputPart {
kind: InputPartKind::StepInput,
label: "example workflows".to_owned(),
approx_tokens: u32::try_from(text.len() / 4).unwrap_or(u32::MAX),
text,
source: None,
}
}
pub fn examples(host: &Host) -> Vec<WorkflowExample> {
let stats = crate::run_review::outcomes::read_all(&crate::divide::repo_state_dir(host))
.map(|lines| argonauts_learning::outcomes::workflow_stats(&lines))
.unwrap_or_else(|err| {
tracing::warn!("past runs unavailable for the workflow examples: {err}");
Default::default()
});
let mut seen = BTreeSet::new();
let mut examples = Vec::new();
let mut offer = |name: &str, text: String, workflow: Workflow| {
if seen.insert(name.to_owned()) {
let version = runner::version(&workflow).unwrap_or_default();
let past = stats
.get(&(name.to_owned(), version))
.copied()
.unwrap_or_default();
examples.push(WorkflowExample {
name: name.to_owned(),
description: workflow.description,
yaml: text,
runs: past.runs,
passed: past.passed,
});
}
};
for dir in runner::workflow_dirs(host) {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
let mut files: Vec<_> = entries
.filter_map(|entry| Some(entry.ok()?.path()))
.collect();
files.sort();
for path in files {
let Some(name) = path.file_stem().and_then(|stem| stem.to_str()) else {
continue;
};
if let Some((text, workflow)) = phases_in(&path, name) {
offer(name, text, workflow);
}
}
}
for name in builtin::names() {
if let (Ok(text), Ok(workflow)) = (builtin::example(name), builtin::load(name)) {
offer(name, text, workflow);
}
}
rank_examples(&mut examples);
examples
}
fn phases_in(path: &Path, name: &str) -> Option<(String, Workflow)> {
let text = std::fs::read_to_string(path).ok()?;
match path.extension()?.to_str()? {
"yaml" => yaml::parse(&text).ok().map(|workflow| (text, workflow)),
"toml" => {
let workflow = workflow_files::phases(&text, name).ok()??;
Some((yaml::print(&workflow).ok()?, workflow))
}
_ => None,
}
}
fn example_names(host: &Host) -> Vec<String> {
examples(host)
.into_iter()
.map(|example| example.name)
.collect()
}
pub fn rank_examples(examples: &mut [WorkflowExample]) {
let rate =
|example: &WorkflowExample| f64::from(example.passed + 1) / f64::from(example.runs + 2);
examples.sort_by(|a, b| {
(b.runs > 0)
.cmp(&(a.runs > 0))
.then(b.passed.cmp(&a.passed))
.then(rate(b).total_cmp(&rate(a)))
});
}
fn goal_lines(goal: Value) -> Vec<String> {
let mut lines = Vec::new();
if let Some(summary) = goal.get("summary").and_then(Value::as_str) {
lines.push(summary.to_owned());
}
for criterion in goal
.get("criteria")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
match criterion.get("text").and_then(Value::as_str) {
Some(text) => lines.push(text.to_owned()),
None => lines.push(criterion.to_string()),
}
}
lines
}
const STEP_KINDS: &[(StepKind, &str)] = &[
(StepKind::Read, "each agent reads the request back"),
(
StepKind::Compare,
"compare readings and tag the differences",
),
(
StepKind::Draft,
"one or more agents write a plan or an answer, blind to each other if set",
),
(StepKind::Map, "quote-checked comparison of drafts"),
(
StepKind::FanOut,
"the same task to several workers or roles, in parallel",
),
(
StepKind::ForEach,
"one round of sub-steps per item of an earlier step's output",
),
(
StepKind::Review,
"review a diff or a result against criteria, by another provider",
),
(StepKind::Judge, "score candidates, never the judge's own"),
(StepKind::Critique, "try to disprove a finding or a claim"),
(StepKind::Synthesize, "combine results into one"),
(
StepKind::Build,
"tasks: a worker in a worktree, then its checks and review",
),
(StepKind::Checks, "run the configured check commands"),
(
StepKind::LoopUntil,
"repeat steps until a condition holds, with a hard maximum",
),
(
StepKind::Approval,
"the Understand or plan approval, where you or your policy decide",
),
(StepKind::Merge, "merge and the merge approval"),
(StepKind::Workflow, "run another workflow as one step"),
];
fn resolve(
host: &Host,
output: &DesignOutput,
) -> Result<Option<(Option<WorkflowRef>, Workflow)>, String> {
let mut values = WorkflowParams::default();
for setting in &output.params {
let value: Value = serde_saphyr::from_str(&setting.value)
.unwrap_or_else(|_| Value::String(setting.value.clone()));
values.0.insert(setting.name.clone(), value);
}
let from = output.based_on.as_deref().map(WorkflowRef::new);
let workflow = match (&output.workflow, &output.based_on) {
(Some(text), _) => yaml::parse(text)?,
(None, Some(name)) => runner::lookup(host, name).map_err(|err| err.to_string())?,
(None, None) => return Ok(None),
};
let filled = params::fill(&workflow, &values).map_err(|errors| errors.join("\n"))?;
Ok(Some((from, filled)))
}
fn name_run(host: &Host, workflow: &Workflow, done_when: &[Criterion]) -> EngineResult<()> {
let (named, style) = host.read(|state| {
(
state.run.named.clone(),
state.run.named.as_ref().map(|named| named.style),
)
});
if named.as_ref().is_some_and(|named| {
named.name == workflow.name
&& named.description == workflow.description
&& named.done_when == done_when
}) {
return Ok(());
}
let style = style.unwrap_or_else(|| crate::workflow::naming::style(host));
host.record(
None,
None,
RunEventKind::Run(RunLifecycle::Named(RunNamed {
name: workflow.name.clone(),
description: workflow.description.clone(),
style,
based_on: workflow.based_on.clone(),
done_when: done_when.to_vec(),
})),
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn builtin(name: &str) -> Workflow {
builtin::load(name).unwrap_or_else(|err| panic!("{err}"))
}
#[test]
fn a_change_keeps_the_phases_that_started() {
let current = builtin("orchestrator");
let mut folded = current.clone();
let plan = folded.phases.remove(1);
folded.phases[0].steps.extend(plan.steps);
let mut check = folded.phases[1].clone();
check.name = "Extra check".to_owned();
folded.phases.insert(2, check);
let refused = keeps_what_ran(¤t, &folded, 2).unwrap_err();
assert!(refused.contains("phase 1 (Understand)"), "{refused}");
let mut dropped = current.clone();
dropped.phases.truncate(1);
let refused = keeps_what_ran(¤t, &dropped, 2).unwrap_err();
assert!(refused.contains("drops phase 2 (Plan)"), "{refused}");
let mut later = current.clone();
let mut check = later.phases[2].clone();
check.name = "Extra check".to_owned();
later.phases.insert(3, check);
assert_eq!(keeps_what_ran(¤t, &later, 2), Ok(()));
assert_eq!(keeps_what_ran(¤t, &later, 3), Ok(()));
let mut edited = current.clone();
edited.phases[2].name = "Build again".to_owned();
assert_eq!(keeps_what_ran(¤t, &edited, 2), Ok(()));
assert!(keeps_what_ran(¤t, &edited, 3).is_err());
}
}