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;
35mod 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
49const SKILL_RUN_SCHEMA: &str = "runx.skill_run.v1";
53const GRAPH_SKILL_STATE_SCHEMA: &str = "runx.graph_skill_state.v1";
54
55#[derive(Debug, Error)]
56pub enum SkillRunError {
57 #[error("skill run failed: {0}")]
58 Invalid(String),
59 #[error(transparent)]
60 Runtime(#[from] RuntimeError),
61 #[error(transparent)]
62 ReceiptStore(#[from] ReceiptStoreError),
63}
64
65#[derive(Clone, Debug, Default)]
74pub(crate) struct SkillRunOverrides {
75 pub(crate) runner: Option<String>,
77 pub(crate) seeded_answers: Option<JsonObject>,
81}
82
83pub(crate) fn execute_skill_run_with_effects(
84 request: &SkillRunRequest,
85 effects: &RuntimeEffectRegistry,
86) -> Result<JsonValue, SkillRunError> {
87 execute_skill_run_with_overrides(request, &SkillRunOverrides::default(), effects)
88}
89
90pub(crate) fn execute_skill_run_with_overrides(
91 request: &SkillRunRequest,
92 overrides: &SkillRunOverrides,
93 effects: &RuntimeEffectRegistry,
94) -> Result<JsonValue, SkillRunError> {
95 let raw_workspace = WorkspaceEnv::new(request.env.clone(), request.cwd.clone());
96 let receipts = ReceiptServices::from_env_or_local_development(raw_workspace.env())
97 .map_err(|error| SkillRunError::Invalid(error.to_string()))?;
98 let mut runtime_env = request.env.clone();
99 strip_receipt_signing_env(&mut runtime_env);
100 let workspace = WorkspaceEnv::new(runtime_env, request.cwd.clone());
101 let skill_dir = resolve_skill_dir(&request.skill_path)?;
102 let manifest = load_runner_manifest(&skill_dir)?;
103 let runner = selected_runner(&manifest, overrides.runner.as_deref())?;
104 let skill_env = workspace.skill_env_for_skill(&skill_dir);
105 if runner.source.source_type == runx_parser::SourceKind::CliTool
106 && request.local_credential.is_some()
107 {
108 return Err(invalid(
109 "local credential process-env delivery is not supported for cli-tool runners",
110 ));
111 }
112 let invocation = runner_invocation(
113 &skill_dir,
114 runner,
115 &request.inputs,
116 &skill_env,
117 request.local_credential.as_ref(),
118 )?;
119 if runner.source.source_type == runx_parser::SourceKind::CliTool {
120 return execute_cli_tool_skill_run(
121 request, &workspace, &receipts, &manifest, runner, invocation,
122 );
123 }
124 if runner.source.source_type == runx_parser::SourceKind::Graph {
125 return execute_graph_skill_run(
126 request, overrides, effects, &workspace, &receipts, &manifest, runner,
127 );
128 }
129
130 execute_agent_skill_run(
131 request, overrides, &workspace, &receipts, &manifest, runner, invocation,
132 )
133}
134
135#[derive(Clone, Debug, Serialize)]
141pub struct InlineHarnessReport {
142 pub status: &'static str,
143 pub case_count: usize,
144 pub assertion_error_count: usize,
145 pub assertion_errors: Vec<String>,
146 pub case_names: Vec<String>,
147 pub receipt_ids: Vec<String>,
148 pub graph_case_count: usize,
149}
150
151impl InlineHarnessReport {
152 fn not_declared() -> Self {
153 Self {
154 status: "not_declared",
155 case_count: 0,
156 assertion_error_count: 0,
157 assertion_errors: Vec::new(),
158 case_names: Vec::new(),
159 receipt_ids: Vec::new(),
160 graph_case_count: 0,
161 }
162 }
163}
164
165fn agent_invocation_source_type(
166 value: &str,
167) -> Result<AgentActInvocationSourceType, SkillRunError> {
168 AgentActInvocationSourceType::from_contract_value(value)
169 .ok_or_else(|| invalid(format!("unsupported agent source type {value}")))
170}
171
172fn agent_request(
173 invocation: &SkillInvocation,
174 source_type: AgentActInvocationSourceType,
175) -> Result<JsonValue, SkillRunError> {
176 contract_json_value(&agent_act_resolution_request(invocation, source_type)?)
177}
178
179fn needs_agent_output(run_id: &str, request_id: &str, request: JsonValue) -> JsonObject {
180 let mut output = JsonObject::new();
181 output.insert(
182 "schema".to_owned(),
183 JsonValue::String(SKILL_RUN_SCHEMA.to_owned()),
184 );
185 output.insert(
186 "status".to_owned(),
187 JsonValue::String("needs_agent".to_owned()),
188 );
189 output.insert("run_id".to_owned(), JsonValue::String(run_id.to_owned()));
190 output.insert(
191 "requests".to_owned(),
192 JsonValue::Array(vec![request_for_public_loop(request_id, request)]),
193 );
194 output
195}
196
197fn request_for_public_loop(request_id: &str, request: JsonValue) -> JsonValue {
198 let mut object = match request {
199 JsonValue::Object(object) => object,
200 _ => JsonObject::new(),
201 };
202 object.insert("id".to_owned(), JsonValue::String(request_id.to_owned()));
203 object
204 .entry("kind".to_owned())
205 .or_insert_with(|| JsonValue::String("agent_act".to_owned()));
206 JsonValue::Object(object)
207}
208
209fn read_answer(path: &Path, request_id: &str) -> Result<JsonValue, SkillRunError> {
210 let raw = fs::read_to_string(path)
211 .map_err(|source| RuntimeError::io(format!("reading {}", path.display()), source))?;
212 let value = serde_json::from_str::<JsonValue>(&raw).map_err(|source| {
213 RuntimeError::json(format!("parsing answers file {}", path.display()), source)
214 })?;
215 let answers = match &value {
216 JsonValue::Object(object) => match object.get("answers") {
217 Some(JsonValue::Object(nested)) => nested,
218 _ => object,
219 },
220 _ => return Err(invalid("answers file must be a JSON object")),
221 };
222 answers
223 .get(request_id)
224 .cloned()
225 .ok_or_else(|| invalid(format!("answers file did not include {request_id}")))
226}
227
228fn seal_skill_answer(
229 run_id: &str,
230 runner: &SkillRunnerDefinition,
231 stdout: &str,
232 disposition: ClosureDisposition,
233 signature_config: &RuntimeReceiptSignatureConfig,
234) -> Result<runx_contracts::Receipt, SkillRunError> {
235 let disposition_label = disposition.label();
236 let succeeded = disposition == ClosureDisposition::Closed;
237 let status = if succeeded {
238 InvocationStatus::Success
239 } else {
240 InvocationStatus::Failure
241 };
242 let skill_output = SkillOutput {
243 status,
244 stdout: stdout.to_owned(),
245 stderr: if succeeded {
246 String::new()
247 } else {
248 format!("agent act closed with {disposition_label}")
249 },
250 exit_code: succeeded.then_some(0),
251 duration_ms: 0,
252 metadata: JsonObject::new(),
253 };
254 seal_skill_output(
255 run_id,
256 runner,
257 &skill_output,
258 disposition,
259 format!("agent_act_{disposition_label}"),
260 format!("agent act closed with {disposition_label}"),
261 signature_config,
262 )
263}
264
265fn domain_act_frame(
272 invocation: &SkillInvocation,
273 answer: &JsonValue,
274 governed_effect: Option<&JsonValue>,
275) -> Option<DomainActFrame> {
276 let act = invocation.source.act.as_ref()?;
277 let authority_grant_refs = invocation
281 .credential_delivery
282 .public_observation()
283 .map(|observation| observation.credential_refs.clone())
284 .unwrap_or_default();
285 build_domain_act_frame(
286 act,
287 &invocation.inputs,
288 answer,
289 governed_effect,
290 authority_grant_refs,
291 )
292}
293
294fn build_domain_act_frame(
301 act: &ActDeclaration,
302 inputs: &runx_contracts::JsonObject,
303 reason_source: &JsonValue,
304 governed_effect: Option<&JsonValue>,
305 authority_grant_refs: Vec<runx_contracts::Reference>,
306) -> Option<DomainActFrame> {
307 use runx_contracts::{
308 ActForm, AuthorityAttenuation, AuthoritySubsetProof, AuthorityTerm, DecisionChoice,
309 Reference, ReferenceType,
310 };
311
312 let resolve = |from_key: Option<&str>, literal: Option<&str>| -> Option<String> {
316 from_key
317 .and_then(|key| inputs.get(key))
318 .and_then(JsonValue::as_str)
319 .or(literal)
320 .map(str::trim)
321 .filter(|value| !value.is_empty())
322 .map(str::to_owned)
323 };
324
325 let form = match resolve(act.form_from.as_deref(), act.form.as_deref())
326 .as_deref()
327 .unwrap_or("observation")
328 {
329 "revision" => ActForm::Revision,
330 "reply" => ActForm::Reply,
331 "review" => ActForm::Review,
332 "verification" => ActForm::Verification,
333 _ => ActForm::Observation,
334 };
335 let purpose = resolve(act.purpose_from.as_deref(), act.purpose.as_deref())?;
336 let legitimacy = resolve(act.legitimacy_from.as_deref(), act.legitimacy.as_deref())
337 .unwrap_or_else(|| "Held the declared authority for this act".to_owned());
338
339 let reason = act
341 .reason_from
342 .as_deref()
343 .and_then(|key| reason_source.as_object().and_then(|object| object.get(key)))
344 .and_then(JsonValue::as_str)
345 .map(str::trim)
346 .filter(|value| !value.is_empty())
347 .map_or_else(|| purpose.clone(), str::to_owned);
348
349 let input_ref = |map_key: Option<&str>, reference_type: ReferenceType| -> Option<Reference> {
351 let uri = inputs.get(map_key?).and_then(JsonValue::as_str)?.trim();
352 (!uri.is_empty()).then(|| Reference::with_uri(reference_type, uri.to_owned()))
353 };
354
355 let decision_choice = act
356 .decision_from
357 .as_deref()
358 .and_then(|key| inputs.get(key))
359 .and_then(JsonValue::as_str)
360 .and_then(map_decision_choice)
361 .unwrap_or(DecisionChoice::Close);
362
363 let reference_type = match act.effect_type.as_deref().unwrap_or("artifact") {
364 "act" => ReferenceType::Act,
365 "tracking_item" => ReferenceType::TrackingItem,
366 "receipt" => ReferenceType::Receipt,
367 "provider" | "provider_event" => ReferenceType::ProviderEvent,
368 "provider_thread" => ReferenceType::ProviderThread,
369 "provider_comment" => ReferenceType::ProviderComment,
370 "github_issue" => ReferenceType::GithubIssue,
371 "external_url" => ReferenceType::ExternalUrl,
372 _ => ReferenceType::Artifact,
373 };
374 let prefix = resolve(
375 act.effect_prefix_from.as_deref(),
376 act.effect_prefix.as_deref(),
377 )
378 .unwrap_or_default();
379 let effect_ref = |id: &str| {
380 let id = id.trim();
381 (!id.is_empty())
382 .then(|| Reference::with_uri(reference_type.clone(), format!("{prefix}{id}")))
383 };
384 let mut artifact_refs = Vec::new();
385 if let Some(reference) = act
386 .effect_from_input
387 .as_deref()
388 .and_then(|key| inputs.get(key))
389 .and_then(JsonValue::as_str)
390 .and_then(effect_ref)
391 {
392 artifact_refs.push(reference);
393 }
394 if let Some(reference) = governed_effect
395 .and_then(|effect| {
396 let field = resolve(act.effect_field_from.as_deref(), act.effect_from.as_deref())?;
397 effect
398 .as_object()
399 .and_then(|object| object.get(field.as_str()))
400 .and_then(JsonValue::as_str)
401 .and_then(effect_ref)
402 })
403 .filter(|reference| !artifact_refs.contains(reference))
404 {
405 artifact_refs.push(reference);
406 }
407
408 let authority_terms = act
415 .authority_term_from
416 .as_deref()
417 .and_then(|key| inputs.get(key))
418 .and_then(|value| serde_json::to_value(value).ok())
419 .and_then(|value| serde_json::from_value::<AuthorityTerm>(value).ok())
420 .map(|term| vec![term])
421 .unwrap_or_default();
422 let parent_authority_ref = act
423 .authority_parent_from
424 .as_deref()
425 .and_then(|key| inputs.get(key))
426 .and_then(|value| serde_json::to_value(value).ok())
427 .and_then(|value| serde_json::from_value::<Reference>(value).ok());
428 let subset_proof = act
429 .authority_subset_proof_from
430 .as_deref()
431 .and_then(|key| inputs.get(key))
432 .and_then(|value| serde_json::to_value(value).ok())
433 .and_then(|value| serde_json::from_value::<AuthoritySubsetProof>(value).ok());
434 let authority_attenuation = match (parent_authority_ref, subset_proof) {
435 (Some(parent), Some(proof)) => Some(AuthorityAttenuation {
436 parent_authority_ref: Some(parent),
437 subset_proof: Some(proof),
438 }),
439 _ => None,
440 };
441
442 Some(DomainActFrame {
443 form,
444 purpose: purpose.into(),
445 legitimacy: legitimacy.into(),
446 summary: reason.clone().into(),
447 target_refs: input_ref(act.target_from.as_deref(), ReferenceType::TrackingItem)
448 .into_iter()
449 .collect(),
450 artifact_refs,
451 decision_choice,
452 decision_summary: reason.into(),
453 actor_ref: input_ref(act.actor_from.as_deref(), ReferenceType::Principal)
454 .unwrap_or_else(|| Reference::runx(ReferenceType::Principal, "local_runtime")),
455 authority_grant_refs,
456 authority_scope_refs: input_ref(act.authority_from.as_deref(), ReferenceType::Grant)
457 .into_iter()
458 .collect(),
459 authority_terms,
460 authority_attenuation,
461 previous: input_ref(act.previous_from.as_deref(), ReferenceType::Receipt),
462 })
463}
464
465fn map_decision_choice(value: &str) -> Option<runx_contracts::DecisionChoice> {
467 use runx_contracts::DecisionChoice;
468 match value.trim().to_ascii_lowercase().as_str() {
469 "decline" | "reject" | "rejected" | "deny" | "denied" => Some(DecisionChoice::Decline),
470 "close" | "accept" | "accepted" | "approve" | "approved" | "paid" | "settle"
471 | "settled" => Some(DecisionChoice::Close),
472 "continue" | "claim" | "claimed" | "deliver" | "delivered" => {
473 Some(DecisionChoice::Continue)
474 }
475 "defer" | "deferred" => Some(DecisionChoice::Defer),
476 "escalate" | "escalated" => Some(DecisionChoice::Escalate),
477 "monitor" | "monitored" => Some(DecisionChoice::Monitor),
478 _ => None,
479 }
480}
481
482fn seal_skill_output(
483 run_id: &str,
484 runner: &SkillRunnerDefinition,
485 output: &SkillOutput,
486 disposition: ClosureDisposition,
487 reason_code: String,
488 summary: String,
489 signature_config: &RuntimeReceiptSignatureConfig,
490) -> Result<runx_contracts::Receipt, SkillRunError> {
491 let graph_name = identifier_segment(run_id);
492 let step_id = identifier_segment(&runner.name);
493 let projection = project_step_output(output);
494 Ok(seal_step(
495 StepSeal {
496 graph_name: &graph_name,
497 step_id: &step_id,
498 attempt: 1,
499 output,
500 projection: &projection,
501 created_at: &crate::time::now_iso8601(),
502 authority_grant_refs: Vec::new(),
503 closure: Some(StepSealClosure {
504 disposition,
505 reason_code,
506 summary,
507 }),
508 },
509 signature_config.signature_policy(),
510 )?)
511}
512
513fn answer_disposition(answer: &JsonValue) -> Result<ClosureDisposition, SkillRunError> {
514 agent_answer_disposition_or_closed(answer).map_err(|error| invalid(format!("{error}")))
515}
516
517fn sealed_output(
518 manifest: &SkillRunnerManifest,
519 run_id: &str,
520 skill_output: &SkillOutput,
521 payload: &JsonValue,
522 receipt: &runx_contracts::Receipt,
523 receipt_value: JsonValue,
524) -> JsonObject {
525 let mut execution = JsonObject::new();
526 execution.insert(
527 "stdout".to_owned(),
528 JsonValue::String(skill_output.stdout.clone()),
529 );
530 execution.insert(
531 "stderr".to_owned(),
532 JsonValue::String(skill_output.stderr.clone()),
533 );
534 execution.insert(
535 "exit_code".to_owned(),
536 skill_output.exit_code.map_or(JsonValue::Null, |exit_code| {
537 JsonValue::Number(JsonNumber::I64(i64::from(exit_code)))
538 }),
539 );
540 execution.insert("structured_output".to_owned(), payload.clone());
541 execution.insert("skill_claim".to_owned(), payload.clone());
542 if let Some(observations) = skill_output
543 .metadata
544 .get(crate::adapter::CREDENTIAL_DELIVERY_OBSERVATIONS_METADATA)
545 {
546 execution.insert(
547 crate::adapter::CREDENTIAL_DELIVERY_OBSERVATIONS_METADATA.to_owned(),
548 observations.clone(),
549 );
550 }
551
552 let mut output = JsonObject::new();
553 output.insert(
554 "schema".to_owned(),
555 JsonValue::String(SKILL_RUN_SCHEMA.to_owned()),
556 );
557 output.insert("status".to_owned(), JsonValue::String("sealed".to_owned()));
558 output.insert(
559 "skill_name".to_owned(),
560 JsonValue::String(manifest.skill.clone().unwrap_or_else(|| "skill".to_owned())),
561 );
562 output.insert("run_id".to_owned(), JsonValue::String(run_id.to_owned()));
563 output.insert(
564 "receipt_id".to_owned(),
565 JsonValue::String(receipt.id.to_string()),
566 );
567 output.insert(
568 "closure".to_owned(),
569 JsonValue::Object(closure_output(&receipt.seal)),
570 );
571 output.insert("receipt".to_owned(), receipt_value);
572 output.insert("execution".to_owned(), JsonValue::Object(execution));
573 output.insert("payload".to_owned(), payload.clone());
574 output
575}
576
577fn closure_output(seal: &runx_contracts::Seal) -> JsonObject {
578 let mut closure = JsonObject::new();
579 closure.insert(
580 "disposition".to_owned(),
581 JsonValue::String(seal.disposition.label().to_owned()),
582 );
583 closure.insert(
584 "reason_code".to_owned(),
585 JsonValue::String(seal.reason_code.to_string()),
586 );
587 closure.insert(
588 "summary".to_owned(),
589 JsonValue::String(seal.summary.to_string()),
590 );
591 closure.insert(
592 "closed_at".to_owned(),
593 JsonValue::String(seal.closed_at.to_string()),
594 );
595 closure
596}
597
598fn normalize_request_id(value: &str) -> String {
599 let mut normalized = String::new();
600 let mut replaced = false;
601 for character in value.chars() {
602 if character.is_ascii_alphanumeric() || matches!(character, '_' | '.' | '-') {
603 normalized.push(character);
604 replaced = false;
605 } else if !replaced {
606 normalized.push('_');
607 replaced = true;
608 }
609 }
610 normalized
611}
612
613fn identifier_segment(value: &str) -> String {
614 normalize_request_id(value)
615 .trim_matches(['.', '_', '-'])
616 .replace('.', "-")
617}
618
619fn contract_json_value(value: &impl serde::Serialize) -> Result<JsonValue, SkillRunError> {
620 let value = serde_json::to_value(value)
621 .map_err(|source| RuntimeError::json("serializing native skill contract value", source))?;
622 serde_json::from_value(value).map_err(|source| {
623 RuntimeError::json("normalizing native skill contract value", source).into()
624 })
625}
626
627fn invalid(message: impl Into<String>) -> SkillRunError {
628 SkillRunError::Invalid(message.into())
629}