1use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8pub use crate::types::{ArtifactName, EvalId};
9mod identities;
10pub use identities::{
11 ChildPrefix, EndpointId, ExecutionPinName, HubEpoch, LogicalPath, ModelId, MountAlias,
12 ProfileVariantName, ReferenceName, ToolName, ToolOperationName,
13};
14use serde_json::{Map, Value};
15use thiserror::Error;
16
17mod discovery;
18mod location;
19pub use discovery::read_workspace_config;
20use discovery::{logical_join, read_regular_text};
21mod plain;
22mod stored;
23mod tools;
24pub(crate) mod validation;
25pub use stored::{Field, StoredPayload, StoredProfile};
26
27pub use tools::{
28 AgentTool, Builtin, BuiltinTool, CommandTool, HumanBuiltinTool, HumanCommandTool, HumanTool,
29 HumanToolKind, SessionCommand, ToolProtocol, ToolSession,
30};
31
32use location::Location;
33pub(crate) use validation::identifier;
34use validation::{path, paths, positive_integer, present, script, tags, text, timeout};
35
36pub const CONFIG_FORMAT_VERSION: u32 = 1;
38pub const CONFIG_FILE: &str = "index.artf";
39const MAX_DECLARED_ITEMS: usize = 64;
42pub const IGNORE_FILE: &str = ".artfignore";
43
44#[derive(Debug, Error)]
45#[error("{}: {message}", crate::platform::path_text(.path))]
46pub struct ConfigError {
47 pub path: PathBuf,
48 pub message: String,
49}
50
51impl ConfigError {
52 pub(crate) fn new(path: impl Into<PathBuf>, message: impl ToString) -> Self {
53 Self {
54 path: path.into(),
55 message: message.to_string(),
56 }
57 }
58
59 pub(crate) fn declaration(
61 path: impl Into<PathBuf>,
62 keys: &[&str],
63 message: impl ToString,
64 ) -> Self {
65 let path = path.into();
66 let message = message.to_string();
67 let message = read_regular_text(&path).map_or(message.clone(), |source| {
68 location::error_at(&source, keys, &message)
69 });
70 Self { path, message }
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
75#[serde(rename_all = "lowercase")]
76pub enum Backend {
77 Anthropic,
79 Codex,
80 Openai,
81}
82
83const REMOVED_BACKENDS: [&str; 2] = ["chatgpt", "claude"];
85
86impl<'de> Deserialize<'de> for Backend {
87 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
88 use serde::de::Error;
89 let name = String::deserialize(deserializer)?;
90 match name.as_str() {
91 "openai" => Ok(Self::Openai),
92 "anthropic" => Ok(Self::Anthropic),
93 "codex" => Ok(Self::Codex),
94 removed if REMOVED_BACKENDS.contains(&removed) => Err(D::Error::custom(format!(
95 r#"backend "{removed}" was removed in 0.5.0; use "openai" or "anthropic" with an API key, or "codex" with a ChatGPT/Codex sign-in"#
96 ))),
97 other => Err(D::Error::unknown_variant(
98 other,
99 &["openai", "anthropic", "codex"],
100 )),
101 }
102 }
103}
104
105impl std::fmt::Display for Backend {
106 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 formatter.write_str(match self {
108 Self::Openai => "openai",
109 Self::Anthropic => "anthropic",
110 Self::Codex => "codex",
111 })
112 }
113}
114
115impl Backend {
116 pub fn as_str(self) -> &'static str {
117 match self {
118 Self::Anthropic => "anthropic",
119 Self::Codex => "codex",
120 Self::Openai => "openai",
121 }
122 }
123 pub fn validate_reasoning(self, reasoning: &str) -> Result<(), String> {
124 let supported = match self {
125 Self::Openai | Self::Codex => matches!(
127 reasoning,
128 "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
129 ),
130 Self::Anthropic => matches!(reasoning, "low" | "medium" | "high" | "max"),
131 };
132 if supported {
133 Ok(())
134 } else {
135 Err(format!(
136 "Unsupported reasoning {reasoning:?} for {self:?}; effort is never remapped."
137 ))
138 }
139 }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "lowercase")]
145pub enum Reasoning {
146 None,
147 Minimal,
148 Low,
149 Medium,
150 High,
151 Xhigh,
152 Max,
153}
154impl Reasoning {
155 pub fn as_str(self) -> &'static str {
156 match self {
157 Self::None => "none",
158 Self::Minimal => "minimal",
159 Self::Low => "low",
160 Self::Medium => "medium",
161 Self::High => "high",
162 Self::Xhigh => "xhigh",
163 Self::Max => "max",
164 }
165 }
166}
167impl std::fmt::Display for Reasoning {
168 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169 formatter.write_str(self.as_str())
170 }
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
174#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
175pub enum Profile {
176 Agent {
177 backend: Backend,
178 model: ModelId,
179 #[serde(default, deserialize_with = "present")]
180 reasoning: Option<Reasoning>,
181 #[serde(
182 rename(serialize = "timeoutMs", deserialize = "timeout_ms"),
183 default,
184 deserialize_with = "timeout",
185 serialize_with = "validation::milliseconds::serialize"
186 )]
187 timeout_ms: Option<std::time::Duration>,
188 #[serde(
189 rename(serialize = "maxToolCalls", deserialize = "max_tool_calls"),
190 default,
191 deserialize_with = "positive_integer"
192 )]
193 max_tool_calls: Option<u64>,
194 #[serde(
195 rename(serialize = "maxTokens", deserialize = "max_tokens"),
196 default,
197 deserialize_with = "positive_integer"
198 )]
199 max_tokens: Option<u64>,
200 },
201 Human {},
202 Dependency {
203 #[serde(rename(serialize = "dependsOn", deserialize = "depends_on"))]
204 depends_on: Vec<ArtifactName>,
205 },
206 Runtime {
207 command: String,
208 args: Vec<crate::scope::Argument>,
209 #[serde(
210 rename(serialize = "timeoutMs", deserialize = "timeout_ms"),
211 default,
212 deserialize_with = "timeout",
213 serialize_with = "validation::milliseconds::serialize"
214 )]
215 timeout_ms: Option<std::time::Duration>,
216 },
217}
218
219impl Profile {
220 fn validate(&self, location: &Location<'_, '_>) -> Result<(), String> {
221 match self {
222 Self::Agent {
223 backend,
224 model,
225 reasoning,
226 ..
227 } => {
228 location
229 .child("model")
230 .check(text(model, "Agent profile model"))?;
231 if let Some(reasoning) = reasoning {
232 location
233 .child("reasoning")
234 .check(backend.validate_reasoning(reasoning.as_str()))?;
235 }
236 Ok(())
237 }
238 Self::Human {} => Ok(()),
239 Self::Dependency { depends_on } => {
240 if depends_on.is_empty()
241 || depends_on.len() > MAX_DECLARED_ITEMS
242 || depends_on.iter().collect::<BTreeSet<_>>().len() != depends_on.len()
243 {
244 return Err(location.child("depends_on").error(
245 "Dependency profile depends_on must contain 1–64 unique Artifact names.",
246 ));
247 }
248 for name in depends_on {
249 location
250 .child("depends_on")
251 .check(identifier(name, "Dependency Artifact name"))?;
252 }
253 Ok(())
254 }
255 Self::Runtime { command, args, .. } => location.check(script(command, args)),
256 }
257 }
258
259 pub fn kind(&self) -> ProfileKind {
260 match self {
261 Self::Agent { .. } => ProfileKind::Agent,
262 Self::Human {} => ProfileKind::Human,
263 Self::Dependency { .. } => ProfileKind::Dependency,
264 Self::Runtime { .. } => ProfileKind::Runtime,
265 }
266 }
267}
268
269#[derive(
271 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, clap::ValueEnum,
272)]
273#[serde(rename_all = "lowercase")]
274pub enum ProfileKind {
275 Runtime,
277 Agent,
279 Human,
281 Dependency,
283}
284
285impl ProfileKind {
286 pub fn name(self) -> &'static str {
287 match self {
288 Self::Runtime => "runtime",
289 Self::Agent => "agent",
290 Self::Human => "human",
291 Self::Dependency => "dependency",
292 }
293 }
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct EvalPayload {
300 pub instruction: String,
301 #[serde(flatten)]
302 pub extra: Map<String, Value>,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
308#[serde(transparent)]
309pub struct LocalEvalId(String);
310
311impl<'de> Deserialize<'de> for LocalEvalId {
312 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
313 String::deserialize(deserializer)?
314 .parse()
315 .map_err(serde::de::Error::custom)
316 }
317}
318
319impl std::str::FromStr for LocalEvalId {
320 type Err = String;
321 fn from_str(value: &str) -> Result<Self, Self::Err> {
322 identifier(value, "Eval id")?;
323 Ok(Self(value.to_owned()))
324 }
325}
326
327impl LocalEvalId {
328 pub fn as_str(&self) -> &str {
329 &self.0
330 }
331}
332
333impl PartialEq<&str> for LocalEvalId {
334 fn eq(&self, other: &&str) -> bool {
335 self.0 == *other
336 }
337}
338
339impl std::ops::Deref for LocalEvalId {
340 type Target = str;
341 fn deref(&self) -> &str {
342 &self.0
343 }
344}
345
346impl std::fmt::Display for LocalEvalId {
347 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
348 self.0.fmt(f)
349 }
350}
351
352#[derive(Debug, Clone, Serialize)]
353#[serde(rename_all = "camelCase")]
354pub struct EvalDeclaration {
355 pub id: LocalEvalId,
356 pub title: String,
357 #[serde(flatten)]
358 body: EvalBody,
359}
360
361#[derive(Debug, Clone)]
364enum EvalBody {
365 Dependency {
366 profile: Profile,
367 },
368 Executable {
369 profile: Profile,
370 profile_variants: BTreeMap<ProfileVariantName, Profile>,
371 payload: EvalPayload,
372 pass_schema: Option<Map<String, Value>>,
373 fail_schema: Option<Map<String, Value>>,
374 },
375}
376
377impl EvalDeclaration {
378 pub fn profile(&self) -> &Profile {
379 match &self.body {
380 EvalBody::Dependency { profile } | EvalBody::Executable { profile, .. } => profile,
381 }
382 }
383 pub fn payload(&self) -> Option<&EvalPayload> {
384 match &self.body {
385 EvalBody::Dependency { .. } => None,
386 EvalBody::Executable { payload, .. } => Some(payload),
387 }
388 }
389 pub fn profile_variants(&self) -> &BTreeMap<ProfileVariantName, Profile> {
390 static EMPTY: std::sync::OnceLock<BTreeMap<ProfileVariantName, Profile>> =
391 std::sync::OnceLock::new();
392 match &self.body {
393 EvalBody::Dependency { .. } => EMPTY.get_or_init(BTreeMap::new),
394 EvalBody::Executable {
395 profile_variants, ..
396 } => profile_variants,
397 }
398 }
399 pub fn pass_schema(&self) -> Option<&Map<String, Value>> {
400 match &self.body {
401 EvalBody::Dependency { .. } => None,
402 EvalBody::Executable { pass_schema, .. } => pass_schema.as_ref(),
403 }
404 }
405 pub fn fail_schema(&self) -> Option<&Map<String, Value>> {
406 match &self.body {
407 EvalBody::Dependency { .. } => None,
408 EvalBody::Executable { fail_schema, .. } => fail_schema.as_ref(),
409 }
410 }
411 #[cfg(test)]
412 pub(crate) fn profile_mut(&mut self) -> &mut Profile {
413 match &mut self.body {
414 EvalBody::Dependency { profile } | EvalBody::Executable { profile, .. } => profile,
415 }
416 }
417 #[cfg(test)]
418 pub(crate) fn pass_schema_mut(&mut self) -> &mut Option<Map<String, Value>> {
419 match &mut self.body {
420 EvalBody::Executable { pass_schema, .. } => pass_schema,
421 _ => panic!("dependency schema is impossible"),
422 }
423 }
424 #[cfg(test)]
425 pub(crate) fn fail_schema_mut(&mut self) -> &mut Option<Map<String, Value>> {
426 match &mut self.body {
427 EvalBody::Executable { fail_schema, .. } => fail_schema,
428 _ => panic!("dependency schema is impossible"),
429 }
430 }
431 pub(crate) fn select_profile(&mut self, name: &ProfileVariantName) -> Result<(), String> {
433 let EvalBody::Executable {
434 profile,
435 profile_variants,
436 ..
437 } = &mut self.body
438 else {
439 return Err("Dependency Evals cannot select profile variants.".into());
440 };
441 *profile = profile_variants
442 .get(name)
443 .ok_or_else(|| format!("Unknown profile variant: {name}"))?
444 .clone();
445 Ok(())
446 }
447}
448
449#[derive(Deserialize)]
450#[serde(rename_all = "snake_case", deny_unknown_fields)]
451struct EvalFields {
452 title: String,
453 profile: Profile,
454 #[serde(default, deserialize_with = "present")]
455 profile_variants: Option<BTreeMap<ProfileVariantName, Profile>>,
456 #[serde(default, deserialize_with = "present")]
457 payload: Option<EvalPayload>,
458 #[serde(default, deserialize_with = "present")]
459 pass_schema: Option<Map<String, Value>>,
460 #[serde(default, deserialize_with = "present")]
461 fail_schema: Option<Map<String, Value>>,
462}
463
464impl EvalDeclaration {
465 fn new(id: LocalEvalId, fields: EvalFields) -> Result<Self, String> {
467 if matches!(fields.profile, Profile::Dependency { .. }) {
468 if fields.payload.is_some()
469 || fields.pass_schema.is_some()
470 || fields.fail_schema.is_some()
471 || fields.profile_variants.is_some()
472 {
473 return Err(
474 "Dependency Evals cannot declare payload, pass_schema, fail_schema or profile_variants."
475 .into(),
476 );
477 }
478 } else if fields.payload.is_none() {
479 return Err("Eval payload is required for runtime, agent and human profiles.".into());
480 }
481 let body = if matches!(fields.profile, Profile::Dependency { .. }) {
482 EvalBody::Dependency {
483 profile: fields.profile,
484 }
485 } else {
486 EvalBody::Executable {
487 profile: fields.profile,
488 profile_variants: fields.profile_variants.unwrap_or_default(),
489 payload: fields.payload.expect("executable payload checked above"),
490 pass_schema: fields.pass_schema,
491 fail_schema: fields.fail_schema,
492 }
493 };
494 Ok(Self {
495 id,
496 title: fields.title,
497 body,
498 })
499 }
500}
501
502impl EvalDeclaration {
503 fn validate(&self, location: &Location<'_, '_>) -> Result<(), String> {
504 location
505 .child("title")
506 .check(text(&self.title, "Eval title"))?;
507 if let Some(payload) = self.payload() {
508 location
509 .child("payload")
510 .child("instruction")
511 .check(text(&payload.instruction, "Eval payload.instruction"))?;
512 }
513 self.profile().validate(&location.child("profile"))?;
514 for (key, schema) in [
515 ("pass_schema", self.pass_schema()),
516 ("fail_schema", self.fail_schema()),
517 ] {
518 if let Some(schema) = schema {
519 location
520 .child(key)
521 .check(crate::agent::verdict::validate_schema(schema))?;
522 }
523 }
524 if self.profile_variants().len() > MAX_DECLARED_ITEMS {
525 return Err(location
526 .child("profile_variants")
527 .error("profile_variants must contain at most 64 named profiles."));
528 }
529 for (name, profile) in self.profile_variants() {
530 let variant = location.child("profile_variants").child(name);
531 location
532 .child("profile_variants")
533 .key(name)
534 .check(identifier(name, "Profile variant name"))?;
535 profile.validate(&variant)?;
536 if std::mem::discriminant(profile) != std::mem::discriminant(self.profile()) {
537 return Err(
538 variant.error("Profile variants must retain the declared reviewer kind.")
539 );
540 }
541 }
542 Ok(())
543 }
544}
545
546#[derive(Debug, Clone, Default, Serialize, Deserialize)]
547#[serde(
548 rename_all(serialize = "camelCase", deserialize = "snake_case"),
549 deny_unknown_fields
550)]
551pub struct Views {
552 #[serde(default)]
553 pub agent_sessions: BTreeMap<ToolOperationName, ToolSession>,
554 #[serde(default)]
555 pub agent_tools: BTreeMap<ToolOperationName, AgentTool>,
556 #[serde(default)]
557 pub human_tools: BTreeMap<ToolOperationName, HumanTool>,
558}
559
560impl Views {
561 fn validate(&self, location: &Location<'_, '_>) -> Result<(), String> {
562 for (name, session) in &self.agent_sessions {
563 let sessions = location.child("agent_sessions");
564 sessions
565 .key(name)
566 .check(identifier(name, "Tool session name"))?;
567 sessions
568 .child(name)
569 .child("start")
570 .check(session.start.validate())?;
571 sessions
572 .child(name)
573 .child("stop")
574 .check(session.stop.validate())?;
575 }
576 for (name, tool) in &self.agent_tools {
577 let tools = location.child("agent_tools");
578 if let AgentTool::Command(tool) = tool
579 && let Some(session) = &tool.session
580 && !self.agent_sessions.contains_key(session)
581 {
582 return Err(tools
583 .child(name)
584 .child("session")
585 .error(format!("Unknown tool session: {session}")));
586 }
587 tools.key(name).check(identifier(name, "Agent tool name"))?;
588 tools
589 .child(name)
590 .check(tool.validate().map_err(|e| format!("Tool {name}: {e}")))?;
591 }
592 for (name, tool) in &self.human_tools {
593 let tools = location.child("human_tools");
594 tools.key(name).check(identifier(name, "Human tool name"))?;
595 tools
596 .child(name)
597 .check(tool.validate().map_err(|e| format!("Tool {name}: {e}")))?;
598 }
599 Ok(())
600 }
601}
602
603#[derive(Debug, Clone, Serialize, Deserialize)]
607#[serde(try_from = "Map<String, Value>", into = "Value")]
608pub enum Fingerprint {
609 Script {
610 command: String,
611 args: Vec<crate::scope::Argument>,
612 files: Vec<LogicalPath>,
614 timeout_ms: Option<std::time::Duration>,
615 },
616 Artifactsum {
617 files: Vec<LogicalPath>,
618 ignore: Vec<String>,
619 },
620}
621
622#[derive(Deserialize)]
623#[serde(deny_unknown_fields)]
624struct ScriptForm {
625 script: ScriptFields,
626}
627
628#[derive(Deserialize)]
629#[serde(rename_all = "snake_case", deny_unknown_fields)]
630struct ScriptFields {
631 command: String,
632 args: Vec<crate::scope::Argument>,
633 #[serde(default)]
634 files: Vec<LogicalPath>,
635 #[serde(
636 default,
637 deserialize_with = "timeout",
638 serialize_with = "validation::milliseconds::serialize"
639 )]
640 timeout_ms: Option<std::time::Duration>,
641}
642
643#[derive(Deserialize)]
644#[serde(deny_unknown_fields)]
645struct ArtifactsumForm {
646 #[serde(default = "owner_root")]
647 files: Vec<LogicalPath>,
648 #[serde(default)]
649 ignore: Vec<String>,
650}
651
652fn owner_root() -> Vec<LogicalPath> {
653 vec![".".parse().expect("owner root logical path")]
654}
655
656impl Default for Fingerprint {
657 fn default() -> Self {
658 Self::Artifactsum {
659 files: owner_root(),
660 ignore: Vec::new(),
661 }
662 }
663}
664
665fn default_fingerprint() -> Option<Fingerprint> {
666 Some(Fingerprint::default())
667}
668
669fn declared_fingerprint<'de, D: serde::Deserializer<'de>>(
670 deserializer: D,
671) -> Result<Option<Fingerprint>, D::Error> {
672 use serde::de::Error;
673 match Value::deserialize(deserializer)? {
674 Value::Bool(false) => Ok(None),
675 Value::Object(object) => Fingerprint::try_from(object)
676 .map(Some)
677 .map_err(D::Error::custom),
678 _ => Err(D::Error::custom(
679 "fingerprint must be false or an object declaring artifactsum (files, ignore) or a script.",
680 )),
681 }
682}
683
684impl TryFrom<Map<String, Value>> for Fingerprint {
685 type Error = serde_json::Error;
686
687 fn try_from(object: Map<String, Value>) -> Result<Self, Self::Error> {
688 Ok(if object.contains_key("script") {
689 let ScriptForm {
690 script:
691 ScriptFields {
692 command,
693 args,
694 files,
695 timeout_ms,
696 },
697 } = serde_json::from_value(Value::Object(object))?;
698 Self::Script {
699 command,
700 args,
701 files,
702 timeout_ms,
703 }
704 } else {
705 if object.contains_key("dependencies") {
706 return Err(serde::de::Error::custom(
707 "fingerprint.dependencies was removed in 0.5: a fingerprint covers only its own Artifact, and an eval's reuse key adds the fingerprints of its mounts, children and referenced Artifacts. Remove the field.",
708 ));
709 }
710 let ArtifactsumForm { files, ignore } = serde_json::from_value(Value::Object(object))?;
711 Self::Artifactsum { files, ignore }
712 })
713 }
714}
715
716impl From<Fingerprint> for Value {
717 fn from(fingerprint: Fingerprint) -> Self {
718 match fingerprint {
719 Fingerprint::Script {
720 command,
721 args,
722 files,
723 timeout_ms,
724 } => serde_json::json!({
725 "script": {
726 "command": command,
727 "args": args,
728 "files": files,
729 "timeoutMs": timeout_ms.map(|value| value.as_millis()),
730 },
731 }),
732 Fingerprint::Artifactsum { files, ignore } => {
733 serde_json::json!({"files": files, "ignore": ignore})
734 }
735 }
736 }
737}
738
739impl Fingerprint {
740 fn validate(&self, location: &Location<'_, '_>) -> Result<(), String> {
741 match self {
742 Self::Script {
743 command,
744 args,
745 files,
746 ..
747 } => {
748 location.child("script").check(script(command, args))?;
749 location
750 .child("script")
751 .child("files")
752 .check(paths(files, "fingerprint.script.files"))
753 }
754 Self::Artifactsum { files, ignore, .. } => {
755 if files.is_empty()
756 || files.len() > MAX_DECLARED_ITEMS
757 || files.iter().collect::<BTreeSet<_>>().len() != files.len()
758 {
759 return Err(location.child("files").error(
760 "fingerprint.files must contain 1–64 unique owner-relative paths.",
761 ));
762 }
763 for file in files.iter().filter(|file| *file != ".") {
764 location.child("files").check(
765 path(file).map_err(|message| format!("fingerprint.files: {message}")),
766 )?;
767 }
768 location
769 .child("ignore")
770 .check(crate::cache::ignore_patterns(ignore))
771 }
772 }
773 }
774}
775
776#[derive(Debug, Clone, Serialize, Deserialize)]
777#[serde(rename_all = "lowercase")]
778pub enum DependencyGates {
779 Green,
780 Ignore,
781}
782
783#[derive(Debug, Clone, Serialize, Deserialize)]
784#[serde(
785 rename_all(serialize = "camelCase", deserialize = "snake_case"),
786 deny_unknown_fields
787)]
788pub struct ReviewPolicy {
789 #[serde(default, deserialize_with = "present")]
790 pub dependency_gates: Option<DependencyGates>,
791}
792
793#[derive(Debug, Clone, Deserialize)]
794#[serde(rename_all = "snake_case", deny_unknown_fields)]
795pub struct ArtifactDeclaration {
796 pub name: ArtifactName,
797 #[serde(default)]
798 pub tags: Vec<String>,
799 #[serde(default, deserialize_with = "declared_evals")]
800 pub evals: Vec<EvalDeclaration>,
801 #[serde(default)]
802 pub views: Views,
803 #[serde(default)]
804 pub mounts: BTreeMap<MountAlias, ArtifactName>,
805 #[serde(default, deserialize_with = "present")]
806 pub basis: Option<bool>,
807 #[serde(
808 default = "default_fingerprint",
809 deserialize_with = "declared_fingerprint"
810 )]
811 pub fingerprint: Option<Fingerprint>,
812 #[serde(default, deserialize_with = "present")]
813 pub review_policy: Option<ReviewPolicy>,
814}
815
816impl ArtifactDeclaration {
817 fn validate(&self, location: &Location<'_, '_>) -> Result<(), String> {
818 location
819 .child("name")
820 .check(identifier(&self.name, "Artifact name"))?;
821 location.child("tags").check(tags(&self.tags))?;
822 for eval in &self.evals {
823 eval.validate(&location.child("evals").key(&eval.id))
824 .map_err(|message| format!("Eval {}: {message}", eval.id))?;
825 }
826 if self.basis == Some(true) && !self.evals.is_empty() {
827 return Err(location
828 .child("basis")
829 .error(format!("Basis Artifact {} cannot own Evals.", self.name)));
830 }
831 self.views.validate(&location.child("views"))?;
832 for (alias, target) in &self.mounts {
833 let mounts = location.child("mounts");
834 mounts.key(alias).check(identifier(alias, "Mount alias"))?;
835 mounts
836 .child(alias)
837 .check(identifier(target, "Mount target"))?;
838 }
839 if let Some(fingerprint) = &self.fingerprint {
840 fingerprint.validate(&location.child("fingerprint"))?;
841 }
842 Ok(())
843 }
844}
845
846fn declared_evals<'de, D: serde::Deserializer<'de>>(
848 deserializer: D,
849) -> Result<Vec<EvalDeclaration>, D::Error> {
850 use serde::de::Error;
851 BTreeMap::<String, EvalFields>::deserialize(deserializer)?
852 .into_iter()
853 .map(|(id, fields)| {
854 let id = id.parse().map_err(D::Error::custom)?;
855 EvalDeclaration::new(id, fields).map_err(D::Error::custom)
856 })
857 .collect()
858}
859
860pub fn parse_declaration(source: &str) -> Result<ArtifactDeclaration, String> {
862 let table = toml::de::DeTable::parse(source).map_err(|error| error.to_string())?;
863 let span = table.span();
864 let document = toml::Spanned::new(span, toml::de::DeValue::Table(table.into_inner()));
865 let location = Location::root(source, &document);
866 for (key, value) in document.get_ref().as_table().unwrap() {
867 reject_non_json_values(value, key.get_ref().as_ref(), source)?;
868 }
869 if document.get_ref().get("family").is_some() {
870 return Err(location
871 .child("family")
872 .error("family was removed in 0.9.0; declare each instance as its own Artifact."));
873 }
874 for key in ["stale_key", "stale"] {
875 if document.get_ref().get(key).is_some() {
876 return Err(location.child(key).error(format!(
877 "{key} was renamed to fingerprint: use fingerprint = {{ files = [\".\"], ignore = [] }} or [fingerprint.script]."
878 )));
879 }
880 }
881 if let Some(evals) = document
882 .get_ref()
883 .get("evals")
884 .and_then(|value| value.get_ref().as_table())
885 {
886 for (id, eval) in evals {
887 if eval.get_ref().get("result_check").is_some() {
888 return Err(location
889 .child("evals")
890 .child(id.get_ref())
891 .child("result_check")
892 .error(format!(
893 "Eval {}: result_check was removed in 0.6.0; remove it. The review's tool calls are in its saved session (artifactize session show).",
894 id.get_ref()
895 )));
896 }
897 }
898 }
899 validate_identity_locations(&location, &document)?;
902 if let Some(fingerprint) = document.get_ref().get("fingerprint")
904 && let Some(fields) = fingerprint.get_ref().as_table()
905 {
906 let fingerprint_location = location.child("fingerprint");
907 if fields.contains_key("script") {
908 fingerprint_location.deserialize::<ScriptForm>()?;
909 } else if !fields.contains_key("dependencies") {
910 fingerprint_location.deserialize::<ArtifactsumForm>()?;
911 }
912 }
913 if let Some(tools) = document
914 .get_ref()
915 .get("views")
916 .and_then(|views| views.get_ref().get("agent_tools"))
917 .and_then(|tools| tools.get_ref().as_table())
918 {
919 for (name, tool) in tools {
920 if tool.get_ref().as_table().is_some() {
921 let tool_location = location
922 .child("views")
923 .child("agent_tools")
924 .child(name.get_ref());
925 if tool.get_ref().get("builtin").is_some() {
926 tool_location.deserialize::<BuiltinTool>()?;
927 } else {
928 tool_location.deserialize::<CommandTool>()?;
929 }
930 }
931 }
932 }
933 if let Some(tools) = document
934 .get_ref()
935 .get("views")
936 .and_then(|views| views.get_ref().get("human_tools"))
937 .and_then(|tools| tools.get_ref().as_table())
938 {
939 for (name, _) in tools {
940 location
941 .child("views")
942 .child("human_tools")
943 .child(name.get_ref())
944 .deserialize::<HumanTool>()?;
945 }
946 }
947 if let Some(sessions) = document
948 .get_ref()
949 .get("views")
950 .and_then(|views| views.get_ref().get("agent_sessions"))
951 .and_then(|sessions| sessions.get_ref().as_table())
952 {
953 for (name, _) in sessions {
954 location
955 .child("views")
956 .child("agent_sessions")
957 .child(name.get_ref())
958 .deserialize::<ToolSession>()?;
959 }
960 }
961 let declaration =
962 ArtifactDeclaration::deserialize(toml::de::ValueDeserializer::from(document.clone()))
963 .map_err(|mut error| {
964 error.set_input(Some(source));
965 error.to_string()
966 })?;
967 declaration.validate(&location)?;
968 Ok(declaration)
969}
970
971fn validate_identity_locations(
972 location: &Location<'_, '_>,
973 document: &toml::Spanned<toml::de::DeValue<'_>>,
974) -> Result<(), String> {
975 use toml::de::DeValue;
976 if let Some(name) = document.get_ref().get("name")
977 && let DeValue::String(name) = name.get_ref()
978 {
979 location
980 .child("name")
981 .check(identifier(name, "Artifact name"))?;
982 }
983 if let Some(mounts) = document
984 .get_ref()
985 .get("mounts")
986 .and_then(|value| value.get_ref().as_table())
987 {
988 for (alias, target) in mounts {
989 location
990 .child("mounts")
991 .key(alias.get_ref())
992 .check(identifier(alias.get_ref(), "Mount alias"))?;
993 if let DeValue::String(target) = target.get_ref() {
994 location
995 .child("mounts")
996 .child(alias.get_ref())
997 .check(identifier(target, "Mount target"))?;
998 }
999 }
1000 }
1001 if let Some(evals) = document
1002 .get_ref()
1003 .get("evals")
1004 .and_then(|value| value.get_ref().as_table())
1005 {
1006 for (id, eval) in evals {
1007 let eval_location = location.child("evals").key(id.get_ref());
1008 eval_location.check(identifier(id.get_ref(), "Eval id"))?;
1009 if let Some(model) = eval
1010 .get_ref()
1011 .get("profile")
1012 .and_then(|profile| profile.get_ref().get("model"))
1013 && let DeValue::String(model) = model.get_ref()
1014 {
1015 eval_location
1016 .child("profile")
1017 .child("model")
1018 .check(text(model, "Agent profile model"))?;
1019 }
1020 if let Some(variants) = eval
1021 .get_ref()
1022 .get("profile_variants")
1023 .and_then(|value| value.get_ref().as_table())
1024 {
1025 for (name, profile) in variants {
1026 let variant = eval_location.child("profile_variants").key(name.get_ref());
1027 variant.check(identifier(name.get_ref(), "Profile variant name"))?;
1028 if let Some(model) = profile.get_ref().get("model")
1029 && let DeValue::String(model) = model.get_ref()
1030 {
1031 variant
1032 .child("model")
1033 .check(text(model, "Agent profile model"))?;
1034 }
1035 }
1036 }
1037 }
1038 }
1039 Ok(())
1040}
1041
1042fn reject_non_json_values(
1044 value: &toml::Spanned<toml::de::DeValue<'_>>,
1045 path: &str,
1046 source: &str,
1047) -> Result<(), String> {
1048 use toml::de::DeValue;
1049 let diagnostic = |message| location::positioned(source, value.span().start, path, message);
1050 match value.get_ref() {
1051 DeValue::Datetime(_) => Err(diagnostic(
1052 "TOML date/time values have no JSON equivalent; use a quoted string.",
1053 )),
1054 DeValue::Float(number)
1055 if matches!(
1056 number.as_str(),
1057 "inf" | "+inf" | "-inf" | "nan" | "+nan" | "-nan"
1058 ) =>
1059 {
1060 Err(diagnostic(
1061 "TOML non-finite floats have no JSON equivalent.",
1062 ))
1063 }
1064 DeValue::Array(items) => {
1065 for (index, item) in items.iter().enumerate() {
1066 reject_non_json_values(item, &format!("{path}[{index}]"), source)?;
1067 }
1068 Ok(())
1069 }
1070 DeValue::Table(table) => {
1071 for (key, value) in table {
1072 reject_non_json_values(value, &format!("{path}.{}", key.get_ref()), source)?;
1073 }
1074 Ok(())
1075 }
1076 _ => Ok(()),
1077 }
1078}
1079
1080pub use artifactize_tools::scope::ArtifactKind;
1081
1082fn tool_ids<K: Ord, T: Ord + AsRef<str>>(
1085 targets: &BTreeMap<T, ArtifactName>,
1086 key: impl Fn(&str) -> Result<K, artifactize_tools::scope::ScopeError>,
1087) -> BTreeMap<K, artifactize_tools::scope::ArtifactId> {
1088 targets
1089 .iter()
1090 .map(|(path, name)| {
1091 (
1092 key(path.as_ref()).expect("validated child prefix or mount alias"),
1093 crate::scope::tool_id(name),
1094 )
1095 })
1096 .collect()
1097}
1098
1099#[derive(Debug, Clone, Serialize)]
1100#[serde(rename_all = "camelCase")]
1101pub struct Artifact {
1102 #[serde(with = "crate::platform::path_serde")]
1103 pub path: PathBuf,
1104 pub kind: ArtifactKind,
1105 pub children: BTreeMap<ChildPrefix, ArtifactName>,
1106 pub name: ArtifactName,
1107 pub tags: Vec<String>,
1108 pub views: Views,
1109 pub mounts: BTreeMap<MountAlias, ArtifactName>,
1110 pub basis: Option<bool>,
1111 pub fingerprint: Option<Fingerprint>,
1112 pub review_policy: Option<ReviewPolicy>,
1113}
1114
1115impl Artifact {
1116 pub(crate) fn tool_scope(&self) -> artifactize_tools::scope::Artifact {
1117 artifactize_tools::scope::Artifact {
1118 path: self.path.clone(),
1119 kind: self.kind,
1120 name: self.name.to_string(),
1121 children: tool_ids(&self.children, |path| {
1122 artifactize_tools::scope::ChildPrefix::new(path)
1123 }),
1124 mounts: tool_ids(&self.mounts, |alias| {
1125 artifactize_tools::scope::MountAlias::new(alias)
1126 }),
1127 }
1128 }
1129
1130 pub fn folder(&self) -> &Path {
1132 match self.kind {
1133 ArtifactKind::Folder => &self.path,
1134 ArtifactKind::File => self.path.parent().unwrap_or(Path::new("")),
1135 }
1136 }
1137
1138 pub fn file_name(&self) -> Option<&str> {
1139 (self.kind == ArtifactKind::File)
1140 .then(|| self.path.file_name().and_then(|name| name.to_str()))
1141 .flatten()
1142 }
1143
1144 pub fn declaration_path(&self) -> PathBuf {
1145 match self.kind {
1146 ArtifactKind::Folder => logical_join(&self.path, std::ffi::OsStr::new(CONFIG_FILE)),
1147 ArtifactKind::File => {
1148 let mut path = self.path.as_os_str().to_owned();
1149 path.push(".artf");
1150 PathBuf::from(path)
1151 }
1152 }
1153 }
1154}
1155
1156#[derive(Debug, Serialize)]
1157pub struct Eval {
1158 pub id: EvalId,
1160 pub target: ArtifactName,
1161 pub references: BTreeMap<crate::config::ReferenceName, ArtifactName>,
1162 pub deps: Vec<ArtifactName>,
1163 pub declaration: EvalDeclaration,
1164 #[serde(skip)]
1167 pub variant: Option<ProfileVariantName>,
1168}
1169
1170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1171pub enum ReviewRequirement {
1172 Basis,
1173 Unreviewed,
1174 Evals,
1175}
1176
1177#[derive(Debug)]
1178pub struct RepoConfig {
1179 pub root: PathBuf,
1180 pub artifacts: BTreeMap<ArtifactName, Artifact>,
1181 pub evals: Vec<Eval>,
1182 pub relations: Vec<crate::scope::Relation>,
1183}
1184
1185impl RepoConfig {
1186 pub fn review_requirement(&self, artifact_id: &str) -> Option<ReviewRequirement> {
1188 let artifact = self.artifacts.get(artifact_id)?;
1189 Some(if artifact.basis == Some(true) {
1190 ReviewRequirement::Basis
1191 } else if self.evals.iter().any(|eval| eval.target == artifact_id) {
1192 ReviewRequirement::Evals
1193 } else {
1194 ReviewRequirement::Unreviewed
1195 })
1196 }
1197}
1198
1199#[cfg(test)]
1200mod tests;
1201
1202impl Serialize for EvalBody {
1203 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1204 use serde::ser::SerializeMap;
1205 let mut map = serializer.serialize_map(None)?;
1206 map.serialize_entry("profile", self.profile())?;
1207 match self {
1208 Self::Dependency { .. } => {
1209 map.serialize_entry("passSchema", &Option::<()>::None)?;
1210 map.serialize_entry("failSchema", &Option::<()>::None)?;
1211 }
1212 Self::Executable {
1213 profile_variants,
1214 payload,
1215 pass_schema,
1216 fail_schema,
1217 ..
1218 } => {
1219 if !profile_variants.is_empty() {
1220 map.serialize_entry("profileVariants", profile_variants)?;
1221 }
1222 map.serialize_entry("payload", payload)?;
1223 map.serialize_entry("passSchema", pass_schema)?;
1224 map.serialize_entry("failSchema", fail_schema)?;
1225 }
1226 }
1227 map.end()
1228 }
1229}
1230impl EvalBody {
1231 fn profile(&self) -> &Profile {
1232 match self {
1233 Self::Dependency { profile } | Self::Executable { profile, .. } => profile,
1234 }
1235 }
1236}