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