diff --git a/crates/harness-gateway-client/src/wire/request-tests.rs b/crates/harness-gateway-client/src/wire/request-tests.rs index b4139baad..3b49a3e1d 100644 --- a/crates/harness-gateway-client/src/wire/request-tests.rs +++ b/crates/harness-gateway-client/src/wire/request-tests.rs @@ -69,7 +69,7 @@ fn tool_round(offered: &[Offered]) -> Round { let catalog = ToolCatalog::new(tools).expect("the offered tools are distinct"); let (ctx, requirements) = ctx.with_tools(catalog).prepare(&prompt, Some(model)); assert!(requirements.refusal().is_none(), "the round prepares"); - let mut run = Run::new(Arc::new(prompt), "", ctx); + let mut run = Run::new(Arc::new(prompt), "".into(), ctx); let mut rounds = Vec::new(); let result = loop { let effects = match run.step() { diff --git a/crates/harness-internal/runner/src/harness.rs b/crates/harness-internal/runner/src/harness.rs index f4f862304..c2393bc07 100644 --- a/crates/harness-internal/runner/src/harness.rs +++ b/crates/harness-internal/runner/src/harness.rs @@ -67,8 +67,9 @@ pub struct RunRequest { /// The prompt's Markdown source. The Harness reads the prompt only /// from this text. pub source: String, - /// The run's argument text. - pub args: String, + /// The run's arguments: a JSON string is the prompt's argument text as + /// it stands, and any other value is its JSON text. + pub args: serde_json::Value, /// Text written to the prompt's declared `input:` file before the run /// starts. pub input_text: Option, @@ -235,7 +236,7 @@ impl Harness { model, ui: Some(host.ui()), }; - let prepared = match prepare_noting_cancel(&source, &args, services).await { + let prepared = match prepare_noting_cancel(&source, args, services).await { Ok(prepared) => prepared, Err((PrepareError::Refused { run_id, .. }, true)) => { return Ok(RunReport { diff --git a/crates/harness-internal/runner/src/prepare.rs b/crates/harness-internal/runner/src/prepare.rs index e23f112ca..1aed19cba 100644 --- a/crates/harness-internal/runner/src/prepare.rs +++ b/crates/harness-internal/runner/src/prepare.rs @@ -211,7 +211,7 @@ impl PrepareError { /// refuses a write. pub async fn prepare( source: &str, - args: &str, + args: serde_json::Value, services: Services, ) -> Result { prepare_noting_cancel(source, args, services) @@ -231,7 +231,7 @@ pub async fn prepare( )] pub(crate) async fn prepare_noting_cancel( source: &str, - args: &str, + args: serde_json::Value, services: Services, ) -> Result { let Services { diff --git a/crates/harness-internal/runner/tests/it/effect_loop-broker.rs b/crates/harness-internal/runner/tests/it/effect_loop-broker.rs index a0903eb93..9bb439617 100644 --- a/crates/harness-internal/runner/tests/it/effect_loop-broker.rs +++ b/crates/harness-internal/runner/tests/it/effect_loop-broker.rs @@ -53,7 +53,7 @@ fn infers_then_chats() -> Run { requirements.is_satisfied(), "the bound model satisfies the fixture's one role: {requirements:?}" ); - Run::new(prompt, "", ctx) + Run::new(prompt, "".into(), ctx) } /// One round as the broker saw it: the last message's text and the round. diff --git a/crates/harness-internal/runner/tests/it/effect_loop-cap.rs b/crates/harness-internal/runner/tests/it/effect_loop-cap.rs index 60c8d31f9..45ba9a418 100644 --- a/crates/harness-internal/runner/tests/it/effect_loop-cap.rs +++ b/crates/harness-internal/runner/tests/it/effect_loop-cap.rs @@ -92,7 +92,7 @@ fn run_of(sections: &str) -> Run { requirements.is_satisfied(), "the bound model and the catalog satisfy the fixture: {requirements:?}" ); - Run::new(prompt, "", ctx) + Run::new(prompt, "".into(), ctx) } /// Parks every call until the test adds a permit to `gate`, then answers diff --git a/crates/harness-internal/runner/tests/it/harness.rs b/crates/harness-internal/runner/tests/it/harness.rs index 96d61aafa..4beeb7728 100644 --- a/crates/harness-internal/runner/tests/it/harness.rs +++ b/crates/harness-internal/runner/tests/it/harness.rs @@ -50,7 +50,7 @@ pub(crate) fn request(source: &str) -> RunRequest { RunRequest { name: NAME.to_owned(), source: source.to_owned(), - args: String::new(), + args: "".into(), input_text: None, vfs: VfsRef::default(), host: HostSnapshot { @@ -161,6 +161,49 @@ const INFERS_AND_STORES: &str = "---\nname: infers\ndescription: d\nrunvil: 0\n\ store.write('note.md', 'kept')\n\ return models.infer('asked') .. '|' .. store.read('note.md')\n```\n"; +/// A prompt under the default `args:` declaration that returns its args +/// text beside its `argv.prose`. +const ECHOES_PROSE: &str = "---\nname: prose\ndescription: d\nrunvil: 0\n---\n\n\ + # Title\n\n## Only\n\n```lua\nreturn args .. '|' .. argv.prose\n```\n"; + +/// A prompt declaring a structured `topic` argument that returns +/// `argv.topic` beside its args text. +const ECHOES_TOPIC: &str = "---\nname: topic\ndescription: d\nrunvil: 0\n\ + args:\n topic:\n type: string\n description: the topic\n---\n\n\ + # Title\n\n## Only\n\n```lua\nreturn argv.topic .. '|' .. args\n```\n"; + +/// The final text of a run of `source` with `args`. +async fn final_text(source: &str, args: Value) -> String { + let recorder = Arc::new(MemoryRecorder::new()); + let report = plain_harness(&recorder, ScriptedBroker::replying()) + .run(RunRequest { + args, + ..request(source) + }) + .await + .expect("the run reaches an outcome"); + let RunOutcome::Completed { final_text } = report.outcome else { + panic!("the run completes: {:?}", report.outcome); + }; + final_text +} + +#[tokio::test] +async fn a_string_argument_is_the_prose_of_a_default_declared_prompt() { + assert_eq!( + final_text(ECHOES_PROSE, json!("two words")).await, + "two words|two words" + ); +} + +#[tokio::test] +async fn an_object_argument_is_the_argv_of_a_structured_prompt() { + assert_eq!( + final_text(ECHOES_TOPIC, json!({ "topic": "cats" })).await, + r#"cats|{"topic":"cats"}"# + ); +} + #[tokio::test] async fn run_returns_the_declared_output_and_the_outcome_under_the_requests_name() { let recorder = Arc::new(MemoryRecorder::new()); diff --git a/crates/harness-internal/runner/tests/it/prepare-files.rs b/crates/harness-internal/runner/tests/it/prepare-files.rs index c0bf57cea..9ce66a7ea 100644 --- a/crates/harness-internal/runner/tests/it/prepare-files.rs +++ b/crates/harness-internal/runner/tests/it/prepare-files.rs @@ -38,7 +38,7 @@ async fn prepare_over( let mut services = services(&recorder, bare()); services.vfs = vfs.clone(); services.input_text = input_text.map(str::to_owned); - let prepared = prepare(source, "", services).await; + let prepared = prepare(source, "".into(), services).await; (recorder, prepared) } diff --git a/crates/harness-internal/runner/tests/it/prepare-host-services.rs b/crates/harness-internal/runner/tests/it/prepare-host-services.rs index e9d17e514..9fc2768c8 100644 --- a/crates/harness-internal/runner/tests/it/prepare-host-services.rs +++ b/crates/harness-internal/runner/tests/it/prepare-host-services.rs @@ -67,7 +67,7 @@ async fn prepare_greeter( let recorder = recorder(); let mut services = services(&recorder, building(GREETER)); services.services = run; - let prepared = prepare(GREETS, "", services).await; + let prepared = prepare(GREETS, "".into(), services).await; (recorder, prepared) } diff --git a/crates/harness-internal/runner/tests/it/prepare-input.rs b/crates/harness-internal/runner/tests/it/prepare-input.rs index 36472531a..f2b3d8c72 100644 --- a/crates/harness-internal/runner/tests/it/prepare-input.rs +++ b/crates/harness-internal/runner/tests/it/prepare-input.rs @@ -89,7 +89,7 @@ fn with_input(input: Option>) -> HostServices { /// Prepares `source` under `services` and drives the run to its end. async fn drive_prompt(source: &str, services: Services) -> RunOutcome { let recorder = Arc::clone(&services.recorder); - let prepared = prepare(source, "", services) + let prepared = prepare(source, "".into(), services) .await .expect("the prompt prepares"); drive_run( @@ -158,7 +158,7 @@ async fn a_required_user_input_declaration_on_a_host_without_a_broker_is_refused let recorder = recorder(); let error = prepare( &user_input_prompt(REQUIRED, ASKS_ONCE), - "", + "".into(), user_input_services(&recorder, None), ) .await diff --git a/crates/harness-internal/runner/tests/it/prepare-prelude.rs b/crates/harness-internal/runner/tests/it/prepare-prelude.rs index 036ea9c77..780509b42 100644 --- a/crates/harness-internal/runner/tests/it/prepare-prelude.rs +++ b/crates/harness-internal/runner/tests/it/prepare-prelude.rs @@ -46,7 +46,7 @@ fn script_call(value: &str, section: &str, entry: &str) -> serde_json::Value { #[tokio::test] async fn a_preludes_tool_calls_are_recorded_as_script_calls_from_the_section_that_made_each() { let recorder = recorder(); - let prepared = prepare(SPEAKS, "", services(&recorder, building(SPEAKER))) + let prepared = prepare(SPEAKS, "".into(), services(&recorder, building(SPEAKER))) .await .unwrap(); let run_id = prepared.run_id; diff --git a/crates/harness-internal/runner/tests/it/prepare-ready.rs b/crates/harness-internal/runner/tests/it/prepare-ready.rs index 959b5b5cb..59dde0dce 100644 --- a/crates/harness-internal/runner/tests/it/prepare-ready.rs +++ b/crates/harness-internal/runner/tests/it/prepare-ready.rs @@ -171,7 +171,7 @@ fn request(source: &str) -> RunRequest { RunRequest { name: "session-1".to_owned(), source: source.to_owned(), - args: String::new(), + args: "".into(), input_text: None, vfs: VfsRef::default(), host: HostSnapshot { @@ -211,10 +211,13 @@ async fn a_pack_whose_ready_resolves_late_has_its_tools_in_the_run() { let (host, moves, startup) = slow_host(Phase::Starting); let recorder = recorder(); let (prepared, ()) = tokio::time::timeout(PATIENCE, async { - tokio::join!(prepare(CALLS_SLOW, "", services(&recorder, host)), async { - startup.asked.notified().await; - moves.send(Phase::Ready).unwrap(); - }) + tokio::join!( + prepare(CALLS_SLOW, "".into(), services(&recorder, host)), + async { + startup.asked.notified().await; + moves.send(Phase::Ready).unwrap(); + } + ) }) .await .expect("the Harness waits on the Pack's ready"); @@ -237,7 +240,7 @@ async fn every_starting_pack_is_asked_to_be_ready_before_any_of_them_answers() { let recorder = recorder(); let (prepared, ()) = tokio::time::timeout(PATIENCE, async { tokio::join!( - prepare(COUNTS_OFFERED, "", services(&recorder, host)), + prepare(COUNTS_OFFERED, "".into(), services(&recorder, host)), async { for (_, startup) in &handles { startup.asked.notified().await; @@ -274,7 +277,7 @@ async fn a_declared_pack_whose_ready_fails_refuses_the_run_naming_its_reason() { let reason = "the fixture server refused the handshake"; let (host, _moves, _startup) = slow_host(Phase::Failed(reason)); let recorder = recorder(); - let error = prepare(DECLARES_SLOW, "", services(&recorder, host)) + let error = prepare(DECLARES_SLOW, "".into(), services(&recorder, host)) .await .expect_err("a declared Pack that will not be ready refuses the run"); diff --git a/crates/harness-internal/runner/tests/it/prepare.rs b/crates/harness-internal/runner/tests/it/prepare.rs index 686319234..352268096 100644 --- a/crates/harness-internal/runner/tests/it/prepare.rs +++ b/crates/harness-internal/runner/tests/it/prepare.rs @@ -187,7 +187,7 @@ fn completed(outcome: RunOutcome) -> String { #[tokio::test] async fn an_unmet_requirement_is_refused_with_the_engines_notice_and_its_run_ended_as_failed() { let recorder = recorder(); - let error = prepare(NEEDS_WEB, "", services(&recorder, bare())) + let error = prepare(NEEDS_WEB, "".into(), services(&recorder, bare())) .await .expect_err("a missing required Pack refuses the run"); @@ -221,7 +221,7 @@ async fn an_unmet_requirement_is_refused_with_the_engines_notice_and_its_run_end #[tokio::test] async fn a_prompt_that_does_not_parse_fails_preparation_and_its_run_ends_as_a_parse_failure() { let recorder = recorder(); - let error = prepare(UNCLOSED, "", services(&recorder, bare())) + let error = prepare(UNCLOSED, "".into(), services(&recorder, bare())) .await .expect_err("a prompt without a closed frontmatter does not parse"); @@ -248,10 +248,10 @@ async fn a_prompt_that_does_not_parse_fails_preparation_and_its_run_ends_as_a_pa async fn two_prepared_runs_draw_different_seeds_and_both_begin_at_the_recorder() { let recorder = recorder(); let before = unix_millis_now(); - let first = prepare(PLAIN, "", services(&recorder, bare())) + let first = prepare(PLAIN, "".into(), services(&recorder, bare())) .await .unwrap(); - let second = prepare(PLAIN, "", services(&recorder, bare())) + let second = prepare(PLAIN, "".into(), services(&recorder, bare())) .await .unwrap(); let after = unix_millis_now(); @@ -310,7 +310,7 @@ async fn a_prepared_run_drives_to_its_end_under_its_own_performers() { run_id, performers, .. - } = prepare(PLAIN, "", services(&recorder, bare())) + } = prepare(PLAIN, "".into(), services(&recorder, bare())) .await .unwrap(); let outcome = drive_run( @@ -333,7 +333,7 @@ async fn a_prepared_run_drives_to_its_end_under_its_own_performers() { /// Prepares `source` over `host` and drives the run to its end. pub(crate) async fn drive_over(source: &str, host: HostContext) -> RunOutcome { let recorder = recorder(); - let prepared = prepare(source, "", services(&recorder, host)) + let prepared = prepare(source, "".into(), services(&recorder, host)) .await .unwrap(); drive_run( @@ -387,9 +387,13 @@ async fn a_bare_entry_another_vendor_answers_is_refused_naming_its_fqn() { let recorder = recorder(); let declares_bare = "---\nname: bare\ndescription: d\nrunvil: 0\n\ uses:\n - tools\n---\n\n# Title\n\n## Only\n\nDone.\n"; - let error = prepare(declares_bare, "", services(&recorder, building(TOOLS))) - .await - .expect_err("a bare id means the runvil Pack, which the Host lacks"); + let error = prepare( + declares_bare, + "".into(), + services(&recorder, building(TOOLS)), + ) + .await + .expect_err("a bare id means the runvil Pack, which the Host lacks"); let PrepareError::Refused { error, .. } = error else { panic!("the refusal is a requirements refusal: {error}"); }; diff --git a/crates/harness-internal/runner/tests/it/support.rs b/crates/harness-internal/runner/tests/it/support.rs index 2517548a6..a073b1598 100644 --- a/crates/harness-internal/runner/tests/it/support.rs +++ b/crates/harness-internal/runner/tests/it/support.rs @@ -78,7 +78,7 @@ fn prepared(prompt: Arc, vfs: VfsRef) -> Run { requirements.is_satisfied(), "the runner fixture prompt declares nothing the environment must supply: {requirements:?}" ); - Run::new(prompt, "", ctx) + Run::new(prompt, "".into(), ctx) } /// A Pack-free run over `lua` with a fixed seed and start, over a diff --git a/crates/harness-internal/runner/tests/it/tool_context.rs b/crates/harness-internal/runner/tests/it/tool_context.rs index 6794190f7..35183d1ca 100644 --- a/crates/harness-internal/runner/tests/it/tool_context.rs +++ b/crates/harness-internal/runner/tests/it/tool_context.rs @@ -77,9 +77,13 @@ impl Pack for Record { #[tokio::test] async fn a_tool_writes_through_its_context_and_the_calling_script_reads_it_back() { let recorder = Arc::new(MemoryRecorder::new()); - let prepared = prepare(CALLS_RECORD, "", services(&recorder, building(CONTEXT))) - .await - .unwrap(); + let prepared = prepare( + CALLS_RECORD, + "".into(), + services(&recorder, building(CONTEXT)), + ) + .await + .unwrap(); let outcome = drive_run( prepared.run, prepared.performers, diff --git a/crates/harness/examples/run-prompt.rs b/crates/harness/examples/run-prompt.rs index 38a5855c0..161a09e1e 100644 --- a/crates/harness/examples/run-prompt.rs +++ b/crates/harness/examples/run-prompt.rs @@ -75,7 +75,7 @@ async fn main() -> Result<(), Box> { let request = RunRequest { name: "desk-greet-1".to_owned(), source: GREET.to_owned(), - args: String::new(), + args: "".into(), input_text: Some("desk".to_owned()), vfs: VfsRef::default(), host: HostSnapshot::default(), diff --git a/crates/harness/tests/suite/broker.rs b/crates/harness/tests/suite/broker.rs index b39eab33e..a842b3f9e 100644 --- a/crates/harness/tests/suite/broker.rs +++ b/crates/harness/tests/suite/broker.rs @@ -94,7 +94,7 @@ async fn a_sections_round_reaches_the_broker_as_chat_and_a_nested_infer_round_as .run(RunRequest { name: "rounds-1".to_owned(), source: INFERS_THEN_CHATS.to_owned(), - args: String::new(), + args: "".into(), input_text: None, vfs: VfsRef::default(), host: HostSnapshot { diff --git a/crates/harness/tests/suite/host.rs b/crates/harness/tests/suite/host.rs index e7866691e..625ac70a0 100644 --- a/crates/harness/tests/suite/host.rs +++ b/crates/harness/tests/suite/host.rs @@ -63,7 +63,7 @@ fn request(name: &str, source: &str) -> RunRequest { RunRequest { name: name.to_owned(), source: source.to_owned(), - args: String::new(), + args: "".into(), input_text: None, vfs: VfsRef::default(), host: HostSnapshot::default(), diff --git a/crates/harness/tests/suite/launch.rs b/crates/harness/tests/suite/launch.rs index 649c00da8..3d5f8f15e 100644 --- a/crates/harness/tests/suite/launch.rs +++ b/crates/harness/tests/suite/launch.rs @@ -35,7 +35,7 @@ fn request(source: &str, vfs: VfsRef) -> RunRequest { RunRequest { name: "shouts-1".to_owned(), source: source.to_owned(), - args: String::new(), + args: "".into(), input_text: Some("hello".to_owned()), vfs, host: HostSnapshot::default(), diff --git a/crates/harness/tests/suite/vfs.rs b/crates/harness/tests/suite/vfs.rs index 0c21664d2..cdf0274d6 100644 --- a/crates/harness/tests/suite/vfs.rs +++ b/crates/harness/tests/suite/vfs.rs @@ -140,7 +140,7 @@ async fn a_hosts_edit_of_a_file_the_run_read_conflicts_while_the_run_waits_on_it let request = RunRequest { name: "desk-review-1".to_owned(), source: REVIEW.to_owned(), - args: String::new(), + args: "".into(), input_text: None, vfs: vfs.clone(), host: HostSnapshot::default(), diff --git a/crates/runvil-internal/engine/benches/models_loop.rs b/crates/runvil-internal/engine/benches/models_loop.rs index 2b9384037..e58af61df 100644 --- a/crates/runvil-internal/engine/benches/models_loop.rs +++ b/crates/runvil-internal/engine/benches/models_loop.rs @@ -90,7 +90,7 @@ fn run_once(prompt: &Prompt, chat: &ScriptedChat, context: u32) -> RunResult { if let Some(refusal) = requirements.refusal() { return RunResult::Failure(refusal); } - let run = Run::new(Arc::new(prompt.clone()), "", ctx); + let run = Run::new(Arc::new(prompt.clone()), "".into(), ctx); let (result, _) = drive(run, |_, effect| { perform_locally(effect, &mut |round| { let Effect::Chat { diff --git a/crates/runvil-internal/engine/src/execute/context-bound.rs b/crates/runvil-internal/engine/src/execute/context-bound.rs index 1744e50e5..f41b0e534 100644 --- a/crates/runvil-internal/engine/src/execute/context-bound.rs +++ b/crates/runvil-internal/engine/src/execute/context-bound.rs @@ -81,6 +81,15 @@ pub(super) fn bound_model_set(prompt: &Prompt, bindings: &ModelBindings) -> Mode set } +/// The run's argument text for the arguments `args`: a JSON string as it +/// stands, and any other value as its JSON text. +pub(super) fn args_text(args: serde_json::Value) -> String { + match args { + serde_json::Value::String(text) => text, + value => value.to_string(), + } +} + /// Derives a section's `argv` from the args string under the prompt's /// declaration: a default-declared prompt wraps the interface prose into /// the default shape (`argv.prose`, with the empty string present, not diff --git a/crates/runvil-internal/engine/src/execute/context-tests.rs b/crates/runvil-internal/engine/src/execute/context-tests.rs index ec3e4962c..c1221ab36 100644 --- a/crates/runvil-internal/engine/src/execute/context-tests.rs +++ b/crates/runvil-internal/engine/src/execute/context-tests.rs @@ -33,7 +33,7 @@ fn test_state(prompt: &Prompt) -> RunState { /// The state a run of `prompt` assembles from `config`, as `Run::new` /// assembles it. fn state_over(prompt: &Prompt, config: RunContext) -> RunState { - RunState::new(Arc::new(prompt.clone()), "", config) + RunState::new(Arc::new(prompt.clone()), "".into(), config) } #[test] @@ -68,6 +68,55 @@ fn derived_values_come_from_the_prompt_and_limits() { assert_eq!(ctx.max_tool_iterations(), 24); } +/// The args text and `argv` a run of `prompt` with `args` starts from. +fn args_of(prompt: &str, args: serde_json::Value) -> (String, Option) { + let prompt = Prompt::parse(prompt, "run-context-test") + .0 + .expect("the test prompt parses"); + let ctx = RunState::new( + Arc::new(prompt), + args, + RunContext::new( + "run-context-test", + TEST_SEED, + runvil_types::timestamp::Timestamp::UNIX_EPOCH, + ), + ); + ( + ctx.args().to_string(), + ctx.argv().map(|argv| (**argv).clone()), + ) +} + +#[test] +fn a_string_argument_is_the_args_text_as_it_stands() { + let source = concat!( + "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n", + "# Title\n\n## Only\n\ndone\n", + ); + assert_eq!( + args_of(source, serde_json::json!("two words")), + ( + "two words".to_owned(), + Some(serde_json::json!({ "prose": "two words" })) + ) + ); +} + +#[test] +fn any_other_argument_is_its_json_text_and_a_structured_prompts_argv() { + let source = concat!( + "---\nname: t\ndescription: d\nrunvil: 0\n", + "args:\n topic:\n type: string\n description: the topic\n---\n\n", + "# Title\n\n## Only\n\ndone\n", + ); + let args = serde_json::json!({ "topic": "cats" }); + assert_eq!( + args_of(source, args.clone()), + (r#"{"topic":"cats"}"#.to_owned(), Some(args)) + ); +} + #[test] fn forks_swap_only_their_own_fields() { let ctx = test_state(&test_prompt()); diff --git a/crates/runvil-internal/engine/src/execute/context.rs b/crates/runvil-internal/engine/src/execute/context.rs index 4d0d97fed..8e6ca3071 100644 --- a/crates/runvil-internal/engine/src/execute/context.rs +++ b/crates/runvil-internal/engine/src/execute/context.rs @@ -126,7 +126,7 @@ impl RunState { /// derives from `config`'s seed and `when` renders `config`'s /// `started_at`, so two contexts over the same inputs agree on both. #[must_use] - pub(super) fn new(prompt: Arc, args: &str, config: RunContext) -> Self { + pub(super) fn new(prompt: Arc, args: serde_json::Value, config: RunContext) -> Self { let RunContext { name, seed, @@ -158,7 +158,8 @@ impl RunState { Arc::clone(&execution), report_debug, )); - let derived_argv = bound::derive_argv(&prompt, args).map(Arc::from); + let args = bound::args_text(args); + let derived_argv = bound::derive_argv(&prompt, &args).map(Arc::from); Self { prompt, nonce: GuardNonce::from_seed(&seed), diff --git a/crates/runvil-internal/engine/src/execute/run.rs b/crates/runvil-internal/engine/src/execute/run.rs index 1ac1e3295..628012f18 100644 --- a/crates/runvil-internal/engine/src/execute/run.rs +++ b/crates/runvil-internal/engine/src/execute/run.rs @@ -133,6 +133,11 @@ impl Run { /// Builds a run of `prompt` with the arguments `args` and the context /// `config`. /// + /// A JSON string in `args` is the run's argument text as it stands; + /// any other value is its JSON text. The prompt's `args` global and + /// `{{ args }}` read that text, and `argv` is derived from it under the + /// prompt's `args:` declaration. + /// /// A context that skipped /// [`RunContext::prepare`](super::RunContext::prepare) runs with an /// empty model set. Its tool set holds whatever catalog and roster the @@ -144,7 +149,7 @@ impl Run { /// filesystem declares no store, or when the store's backend fails its /// probe. #[must_use] - pub fn new(prompt: Arc, args: &str, config: RunContext) -> Run { + pub fn new(prompt: Arc, args: serde_json::Value, config: RunContext) -> Run { // The context's one flag, held here so a stillborn run still has // the handle `cancel` and `cancel_handle` name. let cancel = config.cancel.clone(); @@ -163,7 +168,7 @@ impl Run { #[cfg(test)] pub(crate) fn new_for_test( prompt: Arc, - args: &str, + args: serde_json::Value, config: RunContext, edit: impl FnOnce(RunState) -> RunState, ) -> Run { @@ -304,7 +309,11 @@ impl Run { /// prompt that is not a supported runvil prompt, or [`Error::Store`] /// when the handle declares no store or the store's backend fails the /// probe. -fn prepare_state(prompt: Arc, args: &str, config: RunContext) -> Result { +fn prepare_state( + prompt: Arc, + args: serde_json::Value, + config: RunContext, +) -> Result { match prompt.frontmatter().runvil() { Some(0) => {} Some(other) => return Err(Error::UnsupportedVersion(other)), diff --git a/crates/runvil-internal/engine/src/execute/run/tests-drops.rs b/crates/runvil-internal/engine/src/execute/run/tests-drops.rs index df6078ebb..c00881e23 100644 --- a/crates/runvil-internal/engine/src/execute/run/tests-drops.rs +++ b/crates/runvil-internal/engine/src/execute/run/tests-drops.rs @@ -58,7 +58,7 @@ fn bound_sections(sections: &str) -> Run { requirements.refusal().is_none(), "the model fills the prompt's role: {requirements:?}" ); - Run::new(Arc::new(prompt), "", ctx) + Run::new(Arc::new(prompt), "".into(), ctx) } /// Steps `run` to its next effect and answers it `Dropped`, returning the diff --git a/crates/runvil-internal/engine/src/execute/run/tests.rs b/crates/runvil-internal/engine/src/execute/run/tests.rs index ae0d3d85f..380e4ed6e 100644 --- a/crates/runvil-internal/engine/src/execute/run/tests.rs +++ b/crates/runvil-internal/engine/src/execute/run/tests.rs @@ -222,7 +222,7 @@ fn prompt_of(body: &str) -> Arc { /// A run of [`prompt_of`]`(body)`. fn run_of(body: &str, config: RunContext) -> Run { - Run::new(prompt_of(body), "", config) + Run::new(prompt_of(body), "".into(), config) } /// Whether `events` include the run's end boundary. @@ -371,7 +371,7 @@ fn a_child_cancel_handles_cancel_is_observed_by_the_instruction_hook() { prompt_of( "local n = 0\nwhile true do\n n = n + 1\n if n == 1 then log('looping') end\nend", ), - "", + "".into(), run_context().with_cancel(child), |fresh| { state = Some(fresh.clone()); @@ -414,7 +414,7 @@ fn a_context_without_a_caller_handle_shares_its_one_flag_with_prepare_and_the_ru // The flag `prepare` hands the Packs is the context's own. let (ctx, _) = run_context().prepare(&prompt, None); let packs_flag = ctx.cancel.clone(); - let mut run = Run::new(Arc::new(prompt), "", ctx); + let mut run = Run::new(Arc::new(prompt), "".into(), ctx); assert!(!packs_flag.is_cancelled()); assert!(!run.cancel_handle().is_cancelled()); run.cancel(); @@ -470,7 +470,7 @@ fn a_stillborn_run_reports_its_failure_on_the_first_step() { let prompt = Prompt::parse(source, "run-test") .0 .expect("the prompt parses whatever version it declares"); - let mut run = Run::new(Arc::new(prompt), "", run_context()); + let mut run = Run::new(Arc::new(prompt), "".into(), run_context()); let Step::Done { result, events } = run.step() else { panic!("a run that cannot start is done at once"); }; diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chain_record/arena.rs b/crates/runvil-internal/engine/src/execute/scheduler/chain_record/arena.rs index 0988f35f0..a3c1bc37f 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/chain_record/arena.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/chain_record/arena.rs @@ -65,7 +65,7 @@ impl Chain { .expect("the test prompt parses"); let ctx = RunState::new( Arc::new(prompt), - "", + "".into(), RunContext::new("chain-test", TEST_SEED, TEST_STARTED_AT), ); Chain { diff --git a/crates/runvil-internal/engine/src/execute/tests/run_inputs.rs b/crates/runvil-internal/engine/src/execute/tests/run_inputs.rs index e336d4cfe..22287a978 100644 --- a/crates/runvil-internal/engine/src/execute/tests/run_inputs.rs +++ b/crates/runvil-internal/engine/src/execute/tests/run_inputs.rs @@ -28,9 +28,10 @@ const DRAWS: u64 = 0x5EED; /// the run's text. fn run_seeded(md: &str, seed: RunSeed) -> RunResult { let ctx = RunContext::new(EXECUTION, seed, STARTED_AT); - let (result, _) = drive(Run::new(Arc::new(parse(md)), "", ctx), |_, effect| { - panic!("no effect is issued: {effect:?}") - }); + let (result, _) = drive( + Run::new(Arc::new(parse(md)), "".into(), ctx), + |_, effect| panic!("no effect is issued: {effect:?}"), + ); result } @@ -116,9 +117,10 @@ fn sys_when_is_timestamp_to_rfc3339_for_any_started_at() { ```\n"; let whole_second = Timestamp::from_unix_millis(1_700_000_000_000); let ctx = RunContext::new(EXECUTION, TEST_SEED, whole_second); - let (result, _) = drive(Run::new(Arc::new(parse(md)), "", ctx), |_, effect| { - panic!("no effect is issued: {effect:?}") - }); + let (result, _) = drive( + Run::new(Arc::new(parse(md)), "".into(), ctx), + |_, effect| panic!("no effect is issued: {effect:?}"), + ); let when = text_of(result); assert_eq!(when, whole_second.to_rfc3339()); assert_eq!( @@ -191,9 +193,10 @@ fn the_ui_global_serves_the_snapshot_taken_at_run_start() { ```\n"; let ctx = test_context(EXECUTION).with_ui(json!({ "selected_model": "m-1", "workspace_root": null })); - let (result, _) = drive(Run::new(Arc::new(parse(md)), "", ctx), |_, effect| { - panic!("no effect is issued: {effect:?}") - }); + let (result, _) = drive( + Run::new(Arc::new(parse(md)), "".into(), ctx), + |_, effect| panic!("no effect is issued: {effect:?}"), + ); assert_eq!( text_of(result), "m-1/nil", diff --git a/crates/runvil-internal/engine/src/execute/tests/serial_driver.rs b/crates/runvil-internal/engine/src/execute/tests/serial_driver.rs index 006e99802..05f07544a 100644 --- a/crates/runvil-internal/engine/src/execute/tests/serial_driver.rs +++ b/crates/runvil-internal/engine/src/execute/tests/serial_driver.rs @@ -56,7 +56,7 @@ fn the_driver_runs_a_literal_prompt_with_no_effect() { // A literal return issues nothing, so the performer never runs, and the // events open and close with the run's boundaries. let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n# Title\n\n## Only\n\n```lua\nreturn 'hello'\n```\n"; - let run = Run::new(Arc::new(parse(md)), "", test_context(EXECUTION)); + let run = Run::new(Arc::new(parse(md)), "".into(), test_context(EXECUTION)); let (result, events) = drive(run, |_, effect| panic!("no effect is issued: {effect:?}")); assert_eq!(text_of(result), "hello"); assert!( diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/run_setup.rs b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/run_setup.rs index d3378eef5..27a94b140 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/run_setup.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/run_setup.rs @@ -58,7 +58,7 @@ fn a_store_backend_that_refuses_its_session_fails_the_run_at_the_first_step() { let vfs = VfsRef::builder().store("/", RefusingStore).build(); let mut run = crate::execute::run::Run::new( Arc::new(prompt.expect("the fixture parses")), - "", + "".into(), test_context(EXECUTION).with_vfs(vfs), ); match run.step() { diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/vfs.rs b/crates/runvil-internal/engine/src/execute/tests/suite/vfs.rs index d059d8bfd..3728a8a31 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/vfs.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/vfs.rs @@ -231,7 +231,7 @@ const HELD_STORE: &str = "---\nname: held-store\ndescription: d\nrunvil: 0\n---\ /// Starts the `HELD_STORE` run over `vfs`. fn held_store_run(vfs: &VfsRef) -> Run { let ctx = test_context(EXECUTION).with_vfs(vfs.clone()); - Run::new(Arc::new(parse(HELD_STORE)), "", ctx) + Run::new(Arc::new(parse(HELD_STORE)), "".into(), ctx) } /// Answers `effects` through the local performer, keeping each one in diff --git a/crates/runvil-internal/engine/src/test_support/scenario.rs b/crates/runvil-internal/engine/src/test_support/scenario.rs index 02b918d83..8128d2fbc 100644 --- a/crates/runvil-internal/engine/src/test_support/scenario.rs +++ b/crates/runvil-internal/engine/src/test_support/scenario.rs @@ -344,9 +344,10 @@ impl Scenario { signal, signalled: Cell::new(false), }; - let run = refused - .is_none() - .then(|| self.prefill.start(Arc::new(prompt), &self.args, ctx)); + let run = refused.is_none().then(|| { + self.prefill + .start(Arc::new(prompt), self.args.as_str().into(), ctx) + }); let canceller = run.as_ref().zip(signalled).map(|(run, signalled)| { let cancel = run.cancel_handle(); thread::spawn(move || { @@ -391,7 +392,8 @@ impl Scenario { if let Some(refused) = refused { panic!("expected the prepare to accept the run, got {refused:?}"); } - self.prefill.start(Arc::new(prompt), &self.args, ctx) + self.prefill + .start(Arc::new(prompt), self.args.as_str().into(), ctx) } /// Parses the source and builds the run's context, prepared unless the @@ -440,7 +442,7 @@ struct Prefill { impl Prefill { /// Builds the run of `prompt` under `config` with the state filled in, /// or as [`Run::new`] builds it when there is nothing to fill. - fn start(self, prompt: Arc, args: &str, config: RunContext) -> Run { + fn start(self, prompt: Arc, args: serde_json::Value, config: RunContext) -> Run { if self.models.is_none() && !self.raw_shims { return Run::new(prompt, args, config); } diff --git a/crates/runvil/examples/greeter.rs b/crates/runvil/examples/greeter.rs index a7c7fdb2c..96c308168 100644 --- a/crates/runvil/examples/greeter.rs +++ b/crates/runvil/examples/greeter.rs @@ -103,7 +103,7 @@ pub fn greet() -> Result> { // 4. Create the run, and keep a cancel handle for anything that may // need to stop it. - let mut run = Run::new(Arc::new(prompt), "", ctx); + let mut run = Run::new(Arc::new(prompt), "".into(), ctx); let cancel = run.cancel_handle(); // 5. Step until Done, logging each step's events and answering each diff --git a/crates/runvil/public-api.txt b/crates/runvil/public-api.txt index 438bb4f8f..65458853f 100644 --- a/crates/runvil/public-api.txt +++ b/crates/runvil/public-api.txt @@ -484,7 +484,7 @@ pub fn runvil::Requirements::refusal(&self) -> core::option::Option runvil::cancel::CancelHandle pub fn runvil::Run::decided(&self) -> bool -pub fn runvil::Run::new(prompt: alloc::sync::Arc, args: &str, config: runvil::RunContext) -> runvil::Run +pub fn runvil::Run::new(prompt: alloc::sync::Arc, args: serde_json::value::Value, config: runvil::RunContext) -> runvil::Run pub fn runvil::Run::resume(&mut self, id: runvil::effect::EffectId, answer: runvil::effect::EffectAnswer) pub fn runvil::Run::step(&mut self) -> runvil::Step pub fn runvil::RunContext::flags(&self) -> runvil::replay::Flags diff --git a/crates/runvil/tests/suite/support/scenario.rs b/crates/runvil/tests/suite/support/scenario.rs index 9b62c1b6b..fb6d1ce92 100644 --- a/crates/runvil/tests/suite/support/scenario.rs +++ b/crates/runvil/tests/suite/support/scenario.rs @@ -145,7 +145,7 @@ impl Scenario { return Err(refusal.into()); } Ok(Driver { - run: Run::new(Arc::new(prompt), &self.args, ctx), + run: Run::new(Arc::new(prompt), self.args.as_str().into(), ctx), replies: self.replies.into(), tool: self.tool, hold: self.hold, diff --git a/crates/workshop/agents/src/input-tests.rs b/crates/workshop/agents/src/input-tests.rs index 7a9a1f788..4608fdb94 100644 --- a/crates/workshop/agents/src/input-tests.rs +++ b/crates/workshop/agents/src/input-tests.rs @@ -289,7 +289,7 @@ async fn an_ask_is_answered_when_the_registry_receives_the_text() { let request = RunRequest { name: "conversation-1".to_owned(), source: source.to_owned(), - args: String::new(), + args: "".into(), input_text: None, vfs: VfsRef::default(), host: HostSnapshot::default(), diff --git a/crates/workshop/agents/tests/it/support.rs b/crates/workshop/agents/tests/it/support.rs index 29b7a65d3..38dcc0556 100644 --- a/crates/workshop/agents/tests/it/support.rs +++ b/crates/workshop/agents/tests/it/support.rs @@ -74,7 +74,7 @@ pub(crate) fn harness_for( let request = RunRequest { name: conversation.id().to_string(), source, - args: String::new(), + args: "".into(), input_text: None, vfs: VfsRef::default(), host: HostSnapshot::default(), diff --git a/crates/workshop/server/src/agents.rs b/crates/workshop/server/src/agents.rs index f3b52467d..81bf1c3c1 100644 --- a/crates/workshop/server/src/agents.rs +++ b/crates/workshop/server/src/agents.rs @@ -321,7 +321,7 @@ impl AgentSessions { let request = RunRequest { name: conversation.id().to_string(), source, - args: String::new(), + args: "".into(), input_text: None, vfs: VfsRef::default(), host: host_snapshot(&self.inner.registry), diff --git a/crates/workshop/server/src/agents/tests.rs b/crates/workshop/server/src/agents/tests.rs index 98fad159e..5cbb5aea4 100644 --- a/crates/workshop/server/src/agents/tests.rs +++ b/crates/workshop/server/src/agents/tests.rs @@ -83,7 +83,7 @@ async fn run_prompt(host: &Arc, source: String) -> Option