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