1use std::fs;
10use std::path::Path;
11
12use runx_contracts::{ClosureDisposition, JsonNumber, JsonObject, JsonValue};
13use runx_parser::{ActDeclaration, SkillRunnerDefinition, SkillRunnerManifest};
14use serde::Serialize;
15use thiserror::Error;
16
17use crate::RuntimeError;
18use crate::adapter::{InvocationStatus, SkillInvocation, SkillOutput};
19use crate::agent_invocation::{AgentActInvocationSourceType, agent_act_resolution_request};
20use crate::effects::RuntimeEffectRegistry;
21use crate::execution::disposition::agent_answer_disposition_or_closed;
22use crate::execution::orchestrator::SkillRunRequest;
23use crate::execution::output_projection::project_step_output;
24use crate::receipts::signing::strip_receipt_signing_env;
25use crate::receipts::store::ReceiptStoreError;
26use crate::receipts::{
27 DomainActFrame, RuntimeReceiptSignatureConfig, StepSeal, StepSealClosure, seal_step,
28};
29use crate::services::{ReceiptServices, WorkspaceEnv};
30
31mod agent;
32mod graph;
33mod graph_state;
34mod inline_harness;
35pub(crate) mod runner_manifest;
36
37#[cfg(feature = "cli-tool")]
38pub(crate) use self::graph::SkillRunGraphAdapter;
39pub(crate) use self::graph::graph_domain_act_receipt;
40pub(crate) use self::inline_harness::run_inline_harness_with_effects;
41
42use self::agent::execute_agent_skill_run;
43use self::graph::execute_graph_skill_run;
44use self::runner_manifest::{
45 execute_cli_tool_skill_run, load_runner_manifest, resolve_skill_dir, runner_invocation,
46 selected_runner,
47};
48
49pub use super::operator_context::{
50 SkillOperatorContextChain, SkillOperatorContextContextSkill, SkillOperatorContextDocument,
51 SkillOperatorContextNode, SkillOperatorContextOptions, SkillOperatorContextPackage,
52 SkillOperatorContextRegistry, SkillOperatorContextRunner, SkillOperatorContextStep,
53 SkillOperatorContextTarget, SkillOperatorContextTerminal, SkillOperatorContextTool,
54 load_skill_operator_context_chain,
55};
56pub use super::prepared_skill::{
57 PREPARED_SKILL_REPORT_SCHEMA, PreparedCredentialSummary, PreparedEntryProvenance,
58 PreparedGovernanceSummary, PreparedInputSummary, PreparedRequestSummary, PreparedSkillRun,
59 PreparedSkillRunApproval, PreparedSkillRunReport, PreparedSkillRunStatus, PreparedTraceEntry,
60 prepare_skill_run,
61};
62
63const SKILL_RUN_SCHEMA: &str = "runx.skill_run.v1";
67const GRAPH_SKILL_STATE_SCHEMA: &str = "runx.graph_skill_state.v1";
68
69#[derive(Debug, Error)]
70pub enum SkillRunError {
71 #[error("skill run failed: {0}")]
72 Invalid(String),
73 #[error(transparent)]
74 Runtime(#[from] RuntimeError),
75 #[error(transparent)]
76 ReceiptStore(#[from] ReceiptStoreError),
77}
78
79#[derive(Clone, Debug, Default)]
88pub(crate) struct SkillRunOverrides {
89 pub(crate) runner: Option<String>,
91 pub(crate) seeded_answers: Option<JsonObject>,
95}
96
97pub(crate) fn execute_skill_run_with_effects(
98 request: &SkillRunRequest,
99 effects: &RuntimeEffectRegistry,
100) -> Result<JsonValue, SkillRunError> {
101 execute_skill_run_with_overrides(request, &SkillRunOverrides::default(), effects)
102}
103
104pub(crate) fn execute_skill_run_with_overrides(
105 request: &SkillRunRequest,
106 overrides: &SkillRunOverrides,
107 effects: &RuntimeEffectRegistry,
108) -> Result<JsonValue, SkillRunError> {
109 let skill_dir = resolve_skill_dir(&request.skill_path)?;
110 let manifest = load_runner_manifest(&skill_dir)?;
111 let runner = selected_runner(&manifest, overrides.runner.as_deref())?;
112 execute_skill_run_with_resolved(request, overrides, effects, &skill_dir, &manifest, runner)
113}
114
115pub(crate) fn execute_skill_run_with_resolved(
116 request: &SkillRunRequest,
117 overrides: &SkillRunOverrides,
118 effects: &RuntimeEffectRegistry,
119 skill_dir: &Path,
120 manifest: &SkillRunnerManifest,
121 runner: &SkillRunnerDefinition,
122) -> Result<JsonValue, SkillRunError> {
123 execute_skill_run_with_resolved_trust(
124 request, overrides, effects, skill_dir, manifest, runner, false,
125 )
126}
127
128pub(crate) fn execute_prepared_skill_run_with_resolved(
129 request: &SkillRunRequest,
130 overrides: &SkillRunOverrides,
131 effects: &RuntimeEffectRegistry,
132 skill_dir: &Path,
133 manifest: &SkillRunnerManifest,
134 runner: &SkillRunnerDefinition,
135) -> Result<JsonValue, SkillRunError> {
136 execute_skill_run_with_resolved_trust(
137 request, overrides, effects, skill_dir, manifest, runner, true,
138 )
139}
140
141fn execute_skill_run_with_resolved_trust(
142 request: &SkillRunRequest,
143 overrides: &SkillRunOverrides,
144 effects: &RuntimeEffectRegistry,
145 skill_dir: &Path,
146 manifest: &SkillRunnerManifest,
147 runner: &SkillRunnerDefinition,
148 trusted_prepared: bool,
149) -> Result<JsonValue, SkillRunError> {
150 let raw_workspace = WorkspaceEnv::new(request.env.clone(), request.cwd.clone());
151 let receipts = ReceiptServices::from_env_or_local_development(raw_workspace.env())
152 .map_err(|error| SkillRunError::Invalid(error.to_string()))?;
153 let mut runtime_env = request.env.clone();
154 strip_receipt_signing_env(&mut runtime_env);
155 if !trusted_prepared {
156 super::prepared_skill::strip_untrusted_prepared_env(&mut runtime_env);
157 }
158 let workspace = WorkspaceEnv::new(runtime_env, request.cwd.clone());
159 let skill_env = workspace.skill_env_for_skill(skill_dir);
160 if runner.source.source_type == runx_parser::SourceKind::CliTool
161 && request.local_credential.is_some()
162 {
163 return Err(invalid(
164 "local credential process-env delivery is not supported for cli-tool runners",
165 ));
166 }
167 let invocation = runner_invocation(
168 skill_dir,
169 runner,
170 &request.inputs,
171 &skill_env,
172 request.local_credential.as_ref(),
173 )?;
174 if runner.source.source_type == runx_parser::SourceKind::CliTool {
175 return execute_cli_tool_skill_run(
176 request, &workspace, &receipts, manifest, runner, invocation,
177 );
178 }
179 if runner.source.source_type == runx_parser::SourceKind::Graph {
180 return execute_graph_skill_run(
181 request, overrides, effects, &workspace, &receipts, manifest, runner,
182 );
183 }
184
185 execute_agent_skill_run(
186 request, overrides, &workspace, &receipts, manifest, runner, invocation,
187 )
188}
189
190#[derive(Clone, Debug, Serialize)]
196pub struct InlineHarnessReport {
197 pub status: &'static str,
198 pub case_count: usize,
199 pub assertion_error_count: usize,
200 pub assertion_errors: Vec<String>,
201 pub case_names: Vec<String>,
202 pub receipt_ids: Vec<String>,
203 pub graph_case_count: usize,
204}
205
206impl InlineHarnessReport {
207 fn not_declared() -> Self {
208 Self {
209 status: "not_declared",
210 case_count: 0,
211 assertion_error_count: 0,
212 assertion_errors: Vec::new(),
213 case_names: Vec::new(),
214 receipt_ids: Vec::new(),
215 graph_case_count: 0,
216 }
217 }
218}
219
220fn agent_invocation_source_type(
221 value: &str,
222) -> Result<AgentActInvocationSourceType, SkillRunError> {
223 AgentActInvocationSourceType::from_contract_value(value)
224 .ok_or_else(|| invalid(format!("unsupported agent source type {value}")))
225}
226
227fn agent_request(
228 invocation: &SkillInvocation,
229 source_type: AgentActInvocationSourceType,
230) -> Result<JsonValue, SkillRunError> {
231 contract_json_value(&agent_act_resolution_request(invocation, source_type)?)
232}
233
234fn needs_agent_output(run_id: &str, request_id: &str, request: JsonValue) -> JsonObject {
235 let mut output = JsonObject::new();
236 output.insert(
237 "schema".to_owned(),
238 JsonValue::String(SKILL_RUN_SCHEMA.to_owned()),
239 );
240 output.insert(
241 "status".to_owned(),
242 JsonValue::String("needs_agent".to_owned()),
243 );
244 output.insert("run_id".to_owned(), JsonValue::String(run_id.to_owned()));
245 output.insert(
246 "requests".to_owned(),
247 JsonValue::Array(vec![request_for_public_loop(request_id, request)]),
248 );
249 output
250}
251
252fn request_for_public_loop(request_id: &str, request: JsonValue) -> JsonValue {
253 let mut object = match request {
254 JsonValue::Object(object) => object,
255 _ => JsonObject::new(),
256 };
257 object.insert("id".to_owned(), JsonValue::String(request_id.to_owned()));
258 object
259 .entry("kind".to_owned())
260 .or_insert_with(|| JsonValue::String("agent_act".to_owned()));
261 JsonValue::Object(object)
262}
263
264fn read_answer(path: &Path, request_id: &str) -> Result<JsonValue, SkillRunError> {
265 let raw = fs::read_to_string(path)
266 .map_err(|source| RuntimeError::io(format!("reading {}", path.display()), source))?;
267 let value = serde_json::from_str::<JsonValue>(&raw).map_err(|source| {
268 RuntimeError::json(format!("parsing answers file {}", path.display()), source)
269 })?;
270 let answers = match &value {
271 JsonValue::Object(object) => match object.get("answers") {
272 Some(JsonValue::Object(nested)) => nested,
273 _ => object,
274 },
275 _ => return Err(invalid("answers file must be a JSON object")),
276 };
277 answers
278 .get(request_id)
279 .cloned()
280 .ok_or_else(|| invalid(format!("answers file did not include {request_id}")))
281}
282
283fn seal_skill_answer(
284 run_id: &str,
285 runner: &SkillRunnerDefinition,
286 stdout: &str,
287 disposition: ClosureDisposition,
288 signature_config: &RuntimeReceiptSignatureConfig,
289 env: &std::collections::BTreeMap<String, String>,
290) -> Result<runx_contracts::Receipt, SkillRunError> {
291 let disposition_label = disposition.label();
292 let succeeded = disposition == ClosureDisposition::Closed;
293 let status = if succeeded {
294 InvocationStatus::Success
295 } else {
296 InvocationStatus::Failure
297 };
298 let skill_output = SkillOutput {
299 status,
300 stdout: stdout.to_owned(),
301 stderr: if succeeded {
302 String::new()
303 } else {
304 format!("agent act closed with {disposition_label}")
305 },
306 exit_code: succeeded.then_some(0),
307 duration_ms: 0,
308 metadata: JsonObject::new(),
309 };
310 seal_skill_output(
311 run_id,
312 runner,
313 &skill_output,
314 disposition,
315 format!("agent_act_{disposition_label}"),
316 format!("agent act closed with {disposition_label}"),
317 signature_config,
318 env,
319 )
320}
321
322fn domain_act_frame(
329 invocation: &SkillInvocation,
330 answer: &JsonValue,
331 governed_effect: Option<&JsonValue>,
332) -> Option<DomainActFrame> {
333 let act = invocation.source.act.as_ref()?;
334 let authority_grant_refs = invocation
338 .credential_delivery
339 .public_observation()
340 .map(|observation| observation.credential_refs.clone())
341 .unwrap_or_default();
342 build_domain_act_frame(
343 act,
344 &invocation.inputs,
345 answer,
346 governed_effect,
347 authority_grant_refs,
348 )
349}
350
351fn build_domain_act_frame(
358 act: &ActDeclaration,
359 inputs: &runx_contracts::JsonObject,
360 reason_source: &JsonValue,
361 governed_effect: Option<&JsonValue>,
362 authority_grant_refs: Vec<runx_contracts::Reference>,
363) -> Option<DomainActFrame> {
364 use runx_contracts::{
365 ActForm, AuthorityAttenuation, AuthoritySubsetProof, AuthorityTerm, DecisionChoice,
366 Reference, ReferenceType,
367 };
368
369 let resolve = |from_key: Option<&str>, literal: Option<&str>| -> Option<String> {
373 from_key
374 .and_then(|key| inputs.get(key))
375 .and_then(JsonValue::as_str)
376 .or(literal)
377 .map(str::trim)
378 .filter(|value| !value.is_empty())
379 .map(str::to_owned)
380 };
381
382 let form = match resolve(act.form_from.as_deref(), act.form.as_deref())
383 .as_deref()
384 .unwrap_or("observation")
385 {
386 "revision" => ActForm::Revision,
387 "reply" => ActForm::Reply,
388 "review" => ActForm::Review,
389 "verification" => ActForm::Verification,
390 _ => ActForm::Observation,
391 };
392 let purpose = resolve(act.purpose_from.as_deref(), act.purpose.as_deref())?;
393 let legitimacy = resolve(act.legitimacy_from.as_deref(), act.legitimacy.as_deref())
394 .unwrap_or_else(|| "Held the declared authority for this act".to_owned());
395
396 let reason = act
398 .reason_from
399 .as_deref()
400 .and_then(|key| reason_source.as_object().and_then(|object| object.get(key)))
401 .and_then(JsonValue::as_str)
402 .map(str::trim)
403 .filter(|value| !value.is_empty())
404 .map_or_else(|| purpose.clone(), str::to_owned);
405
406 let input_ref = |map_key: Option<&str>, reference_type: ReferenceType| -> Option<Reference> {
408 let uri = inputs.get(map_key?).and_then(JsonValue::as_str)?.trim();
409 (!uri.is_empty()).then(|| Reference::with_uri(reference_type, uri.to_owned()))
410 };
411
412 let decision_choice = act
413 .decision_from
414 .as_deref()
415 .and_then(|key| inputs.get(key))
416 .and_then(JsonValue::as_str)
417 .and_then(map_decision_choice)
418 .unwrap_or(DecisionChoice::Close);
419
420 let reference_type = match act.effect_type.as_deref().unwrap_or("artifact") {
421 "act" => ReferenceType::Act,
422 "tracking_item" => ReferenceType::TrackingItem,
423 "receipt" => ReferenceType::Receipt,
424 "provider" | "provider_event" => ReferenceType::ProviderEvent,
425 "provider_thread" => ReferenceType::ProviderThread,
426 "provider_comment" => ReferenceType::ProviderComment,
427 "github_issue" => ReferenceType::GithubIssue,
428 "external_url" => ReferenceType::ExternalUrl,
429 _ => ReferenceType::Artifact,
430 };
431 let prefix = resolve(
432 act.effect_prefix_from.as_deref(),
433 act.effect_prefix.as_deref(),
434 )
435 .unwrap_or_default();
436 let effect_ref = |id: &str| {
437 let id = id.trim();
438 (!id.is_empty())
439 .then(|| Reference::with_uri(reference_type.clone(), format!("{prefix}{id}")))
440 };
441 let mut artifact_refs = Vec::new();
442 if let Some(reference) = act
443 .effect_from_input
444 .as_deref()
445 .and_then(|key| inputs.get(key))
446 .and_then(JsonValue::as_str)
447 .and_then(effect_ref)
448 {
449 artifact_refs.push(reference);
450 }
451 if let Some(reference) = governed_effect
452 .and_then(|effect| {
453 let field = resolve(act.effect_field_from.as_deref(), act.effect_from.as_deref())?;
454 effect
455 .as_object()
456 .and_then(|object| object.get(field.as_str()))
457 .and_then(JsonValue::as_str)
458 .and_then(effect_ref)
459 })
460 .filter(|reference| !artifact_refs.contains(reference))
461 {
462 artifact_refs.push(reference);
463 }
464
465 let authority_terms = act
472 .authority_term_from
473 .as_deref()
474 .and_then(|key| inputs.get(key))
475 .and_then(|value| serde_json::to_value(value).ok())
476 .and_then(|value| serde_json::from_value::<AuthorityTerm>(value).ok())
477 .map(|term| vec![term])
478 .unwrap_or_default();
479 let parent_authority_ref = act
480 .authority_parent_from
481 .as_deref()
482 .and_then(|key| inputs.get(key))
483 .and_then(|value| serde_json::to_value(value).ok())
484 .and_then(|value| serde_json::from_value::<Reference>(value).ok());
485 let subset_proof = act
486 .authority_subset_proof_from
487 .as_deref()
488 .and_then(|key| inputs.get(key))
489 .and_then(|value| serde_json::to_value(value).ok())
490 .and_then(|value| serde_json::from_value::<AuthoritySubsetProof>(value).ok());
491 let authority_attenuation = match (parent_authority_ref, subset_proof) {
492 (Some(parent), Some(proof)) => Some(AuthorityAttenuation {
493 parent_authority_ref: Some(parent),
494 subset_proof: Some(proof),
495 }),
496 _ => None,
497 };
498
499 Some(DomainActFrame {
500 form,
501 purpose: purpose.into(),
502 legitimacy: legitimacy.into(),
503 summary: reason.clone().into(),
504 target_refs: input_ref(act.target_from.as_deref(), ReferenceType::TrackingItem)
505 .into_iter()
506 .collect(),
507 artifact_refs,
508 decision_choice,
509 decision_summary: reason.into(),
510 actor_ref: input_ref(act.actor_from.as_deref(), ReferenceType::Principal)
511 .unwrap_or_else(|| Reference::runx(ReferenceType::Principal, "local_runtime")),
512 authority_grant_refs,
513 authority_scope_refs: input_ref(act.authority_from.as_deref(), ReferenceType::Grant)
514 .into_iter()
515 .collect(),
516 authority_terms,
517 authority_attenuation,
518 previous: input_ref(act.previous_from.as_deref(), ReferenceType::Receipt),
519 })
520}
521
522fn map_decision_choice(value: &str) -> Option<runx_contracts::DecisionChoice> {
524 use runx_contracts::DecisionChoice;
525 match value.trim().to_ascii_lowercase().as_str() {
526 "decline" | "reject" | "rejected" | "deny" | "denied" => Some(DecisionChoice::Decline),
527 "close" | "accept" | "accepted" | "approve" | "approved" | "paid" | "settle"
528 | "settled" => Some(DecisionChoice::Close),
529 "continue" | "claim" | "claimed" | "deliver" | "delivered" => {
530 Some(DecisionChoice::Continue)
531 }
532 "defer" | "deferred" => Some(DecisionChoice::Defer),
533 "escalate" | "escalated" => Some(DecisionChoice::Escalate),
534 "monitor" | "monitored" => Some(DecisionChoice::Monitor),
535 _ => None,
536 }
537}
538
539fn seal_skill_output(
540 run_id: &str,
541 runner: &SkillRunnerDefinition,
542 output: &SkillOutput,
543 disposition: ClosureDisposition,
544 reason_code: String,
545 summary: String,
546 signature_config: &RuntimeReceiptSignatureConfig,
547 env: &std::collections::BTreeMap<String, String>,
548) -> Result<runx_contracts::Receipt, SkillRunError> {
549 let graph_name = identifier_segment(run_id);
550 let step_id = identifier_segment(&runner.name);
551 let projection = project_step_output(output);
552 Ok(seal_step(
553 StepSeal {
554 graph_name: &graph_name,
555 step_id: &step_id,
556 attempt: 1,
557 output,
558 projection: &projection,
559 created_at: &crate::time::now_iso8601(),
560 authority_grant_refs: Vec::new(),
561 operator_refs: super::prepared_skill::prepared_receipt_references(env),
562 closure: Some(StepSealClosure {
563 disposition,
564 reason_code,
565 summary,
566 }),
567 },
568 signature_config.signature_policy(),
569 )?)
570}
571
572fn answer_disposition(answer: &JsonValue) -> Result<ClosureDisposition, SkillRunError> {
573 agent_answer_disposition_or_closed(answer).map_err(|error| invalid(format!("{error}")))
574}
575
576fn sealed_output(
577 manifest: &SkillRunnerManifest,
578 run_id: &str,
579 skill_output: &SkillOutput,
580 payload: &JsonValue,
581 receipt: &runx_contracts::Receipt,
582 receipt_value: JsonValue,
583) -> JsonObject {
584 let mut execution = JsonObject::new();
585 execution.insert(
586 "stdout".to_owned(),
587 JsonValue::String(skill_output.stdout.clone()),
588 );
589 execution.insert(
590 "stderr".to_owned(),
591 JsonValue::String(skill_output.stderr.clone()),
592 );
593 execution.insert(
594 "exit_code".to_owned(),
595 skill_output.exit_code.map_or(JsonValue::Null, |exit_code| {
596 JsonValue::Number(JsonNumber::I64(i64::from(exit_code)))
597 }),
598 );
599 execution.insert("structured_output".to_owned(), payload.clone());
600 execution.insert("skill_claim".to_owned(), payload.clone());
601 if let Some(observations) = skill_output
602 .metadata
603 .get(crate::adapter::CREDENTIAL_DELIVERY_OBSERVATIONS_METADATA)
604 {
605 execution.insert(
606 crate::adapter::CREDENTIAL_DELIVERY_OBSERVATIONS_METADATA.to_owned(),
607 observations.clone(),
608 );
609 }
610
611 let mut output = JsonObject::new();
612 output.insert(
613 "schema".to_owned(),
614 JsonValue::String(SKILL_RUN_SCHEMA.to_owned()),
615 );
616 output.insert("status".to_owned(), JsonValue::String("sealed".to_owned()));
617 output.insert(
618 "skill_name".to_owned(),
619 JsonValue::String(manifest.skill.clone().unwrap_or_else(|| "skill".to_owned())),
620 );
621 output.insert("run_id".to_owned(), JsonValue::String(run_id.to_owned()));
622 output.insert(
623 "receipt_id".to_owned(),
624 JsonValue::String(receipt.id.to_string()),
625 );
626 output.insert(
627 "closure".to_owned(),
628 JsonValue::Object(closure_output(&receipt.seal)),
629 );
630 output.insert("receipt".to_owned(), receipt_value);
631 output.insert("execution".to_owned(), JsonValue::Object(execution));
632 output.insert("payload".to_owned(), payload.clone());
633 output
634}
635
636fn closure_output(seal: &runx_contracts::Seal) -> JsonObject {
637 let mut closure = JsonObject::new();
638 closure.insert(
639 "disposition".to_owned(),
640 JsonValue::String(seal.disposition.label().to_owned()),
641 );
642 closure.insert(
643 "reason_code".to_owned(),
644 JsonValue::String(seal.reason_code.to_string()),
645 );
646 closure.insert(
647 "summary".to_owned(),
648 JsonValue::String(seal.summary.to_string()),
649 );
650 closure.insert(
651 "closed_at".to_owned(),
652 JsonValue::String(seal.closed_at.to_string()),
653 );
654 closure
655}
656
657fn normalize_request_id(value: &str) -> String {
658 let mut normalized = String::new();
659 let mut replaced = false;
660 for character in value.chars() {
661 if character.is_ascii_alphanumeric() || matches!(character, '_' | '.' | '-') {
662 normalized.push(character);
663 replaced = false;
664 } else if !replaced {
665 normalized.push('_');
666 replaced = true;
667 }
668 }
669 normalized
670}
671
672fn identifier_segment(value: &str) -> String {
673 normalize_request_id(value)
674 .trim_matches(['.', '_', '-'])
675 .replace('.', "-")
676}
677
678fn contract_json_value(value: &impl serde::Serialize) -> Result<JsonValue, SkillRunError> {
679 let value = serde_json::to_value(value)
680 .map_err(|source| RuntimeError::json("serializing native skill contract value", source))?;
681 serde_json::from_value(value).map_err(|source| {
682 RuntimeError::json("normalizing native skill contract value", source).into()
683 })
684}
685
686fn invalid(message: impl Into<String>) -> SkillRunError {
687 SkillRunError::Invalid(message.into())
688}