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