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};
23mod site;
24pub use mutex::MutexTag;
25pub use site::{ScopeReadError, Site, read_sites};
26use site::{declaration_of, folder_of};
27pub(crate) use site::{read_artifact_scope, read_eval_scope};
28mod plain;
29mod schemas;
30mod stored;
31mod tools;
32pub(crate) mod validation;
33pub use stored::{Field, StoredPayload, StoredProfile};
34pub use validation::MAX_TIMEOUT_MS;
35
36pub use tools::{
37 AgentTool, Builtin, BuiltinTool, CommandTool, HumanBuiltinTool, HumanCommandTool, HumanTool,
38 HumanToolKind, SessionCommand, ToolProtocol, ToolSession,
39};
40
41use location::Location;
42pub(crate) use validation::{artifact_name, identifier};
43use validation::{path, paths, positive_integer, present, script, tags, text, timeout};
44
45pub const CONFIG_FORMAT_VERSION: u32 = 1;
47pub const CONFIG_FILE: &str = "index.artf";
48const MAX_DECLARED_ITEMS: usize = 64;
51pub const IGNORE_FILE: &str = ".artfignore";
52
53#[derive(Debug, Error)]
54#[error("{}: {message}", crate::platform::path_text(.path))]
55pub struct ConfigError {
56 pub path: PathBuf,
57 pub message: String,
58}
59
60impl ConfigError {
61 pub(crate) fn new(path: impl Into<PathBuf>, message: impl ToString) -> Self {
62 Self {
63 path: path.into(),
64 message: message.to_string(),
65 }
66 }
67
68 pub(crate) fn declaration(
70 path: impl Into<PathBuf>,
71 keys: &[&str],
72 message: impl ToString,
73 ) -> Self {
74 let path = path.into();
75 let message = message.to_string();
76 let message = read_regular_text(&path).map_or(message.clone(), |source| {
77 location::error_at(&source, keys, &message)
78 });
79 Self { path, message }
80 }
81}
82
83#[derive(
85 Debug,
86 Clone,
87 Copy,
88 PartialEq,
89 Eq,
90 PartialOrd,
91 Ord,
92 Hash,
93 Serialize,
94 Deserialize,
95 clap::ValueEnum,
96)]
97#[serde(rename_all = "lowercase")]
98pub enum Provider {
99 Codex,
101 #[serde(rename = "claude-agent")]
103 #[value(name = "claude-agent")]
104 ClaudeAgent,
105 Openai,
107 Anthropic,
109 Gemini,
111}
112
113impl Provider {
114 pub const ALL: [Self; 5] = [
116 Self::Codex,
117 Self::ClaudeAgent,
118 Self::Openai,
119 Self::Anthropic,
120 Self::Gemini,
121 ];
122
123 pub fn as_str(self) -> &'static str {
124 match self {
125 Self::Anthropic => "anthropic",
126 Self::ClaudeAgent => "claude-agent",
127 Self::Codex => "codex",
128 Self::Gemini => "gemini",
129 Self::Openai => "openai",
130 }
131 }
132
133 fn named(name: &str) -> Option<Self> {
134 Self::ALL
135 .into_iter()
136 .find(|provider| provider.as_str() == name)
137 }
138}
139
140impl std::fmt::Display for Provider {
141 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 formatter.write_str(self.as_str())
143 }
144}
145
146const REMOVED_BACKENDS: [&str; 2] = ["chatgpt", "claude"];
148
149#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
152#[serde(transparent)]
153pub struct Backend(String);
154
155impl Backend {
156 pub fn as_str(&self) -> &str {
157 &self.0
158 }
159
160 pub fn builtin(&self) -> Option<Provider> {
162 Provider::named(&self.0)
163 }
164}
165
166impl From<Provider> for Backend {
167 fn from(provider: Provider) -> Self {
168 Self(provider.as_str().to_owned())
169 }
170}
171
172impl std::str::FromStr for Backend {
173 type Err = String;
174 fn from_str(name: &str) -> Result<Self, Self::Err> {
175 if REMOVED_BACKENDS.contains(&name) {
176 return Err(format!(
177 r#"backend "{name}" was removed in 0.5.0; use "openai", "anthropic" or "gemini" with an API key, "codex" with a ChatGPT/Codex sign-in, or "claude-agent" with your Claude Code login"#
178 ));
179 }
180 identifier(name, "backend")?;
181 Ok(Self(name.to_owned()))
182 }
183}
184
185impl<'de> Deserialize<'de> for Backend {
186 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
187 String::deserialize(deserializer)?
188 .parse()
189 .map_err(serde::de::Error::custom)
190 }
191}
192
193impl std::fmt::Display for Backend {
194 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195 formatter.write_str(&self.0)
196 }
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "lowercase")]
202pub enum Reasoning {
203 None,
204 Minimal,
205 Low,
206 Medium,
207 High,
208 Xhigh,
209 Max,
210}
211impl Reasoning {
212 pub fn as_str(self) -> &'static str {
213 match self {
214 Self::None => "none",
215 Self::Minimal => "minimal",
216 Self::Low => "low",
217 Self::Medium => "medium",
218 Self::High => "high",
219 Self::Xhigh => "xhigh",
220 Self::Max => "max",
221 }
222 }
223}
224impl std::fmt::Display for Reasoning {
225 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226 formatter.write_str(self.as_str())
227 }
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
231#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
232pub enum Profile {
233 Agent {
234 backend: Backend,
235 model: ModelId,
236 #[serde(default, deserialize_with = "present")]
237 reasoning: Option<Reasoning>,
238 #[serde(
239 rename(serialize = "timeoutMs", deserialize = "timeout_ms"),
240 default,
241 deserialize_with = "timeout",
242 serialize_with = "validation::milliseconds::serialize"
243 )]
244 timeout_ms: Option<std::time::Duration>,
245 #[serde(
246 rename(serialize = "maxToolCalls", deserialize = "max_tool_calls"),
247 default,
248 deserialize_with = "positive_integer"
249 )]
250 max_tool_calls: Option<u64>,
251 #[serde(
252 rename(serialize = "maxTokens", deserialize = "max_tokens"),
253 default,
254 deserialize_with = "positive_integer"
255 )]
256 max_tokens: Option<u64>,
257 },
258 Human {},
259 Runtime {
260 #[serde(serialize_with = "crate::platform::serialize_program")]
261 command: String,
262 args: Vec<crate::scope::Argument>,
263 #[serde(
264 rename(serialize = "timeoutMs", deserialize = "timeout_ms"),
265 default,
266 deserialize_with = "timeout",
267 serialize_with = "validation::milliseconds::serialize"
268 )]
269 timeout_ms: Option<std::time::Duration>,
270 },
271}
272
273impl Profile {
274 fn validate(&self, location: &Location<'_, '_>) -> Result<(), String> {
275 match self {
276 Self::Agent {
277 backend,
278 model,
279 reasoning,
280 ..
281 } => {
282 location
283 .child("model")
284 .check(text(model, "Agent profile model"))?;
285 if let (Some(reasoning), Some(provider)) = (reasoning, backend.builtin()) {
288 location.child("reasoning").check(
289 crate::llm::Api::from(provider).validate_reasoning(reasoning.as_str()),
290 )?;
291 }
292 Ok(())
293 }
294 Self::Human {} => Ok(()),
295 Self::Runtime { command, args, .. } => location.check(script(command, args)),
296 }
297 }
298
299 pub fn kind(&self) -> ProfileKind {
300 match self {
301 Self::Agent { .. } => ProfileKind::Agent,
302 Self::Human {} => ProfileKind::Human,
303 Self::Runtime { .. } => ProfileKind::Runtime,
304 }
305 }
306}
307
308#[derive(
310 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, clap::ValueEnum,
311)]
312#[serde(rename_all = "lowercase")]
313pub enum ProfileKind {
314 Runtime,
316 Agent,
318 Human,
320}
321
322impl ProfileKind {
323 pub fn name(self) -> &'static str {
324 match self {
325 Self::Runtime => "runtime",
326 Self::Agent => "agent",
327 Self::Human => "human",
328 }
329 }
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct EvalPayload {
336 pub instruction: String,
337 #[serde(flatten)]
338 pub extra: Map<String, Value>,
339}
340
341#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
344#[serde(transparent)]
345pub struct LocalEvalId(String);
346
347impl<'de> Deserialize<'de> for LocalEvalId {
348 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
349 String::deserialize(deserializer)?
350 .parse()
351 .map_err(serde::de::Error::custom)
352 }
353}
354
355impl std::str::FromStr for LocalEvalId {
356 type Err = String;
357 fn from_str(value: &str) -> Result<Self, Self::Err> {
358 identifier(value, "Eval id")?;
359 Ok(Self(value.to_owned()))
360 }
361}
362
363impl LocalEvalId {
364 pub fn as_str(&self) -> &str {
365 &self.0
366 }
367}
368
369impl PartialEq<&str> for LocalEvalId {
370 fn eq(&self, other: &&str) -> bool {
371 self.0 == *other
372 }
373}
374
375impl std::ops::Deref for LocalEvalId {
376 type Target = str;
377 fn deref(&self) -> &str {
378 &self.0
379 }
380}
381
382impl std::fmt::Display for LocalEvalId {
383 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
384 self.0.fmt(f)
385 }
386}
387
388#[derive(Debug, Clone, Serialize)]
389#[serde(rename_all = "camelCase")]
390pub struct EvalDeclaration {
391 pub id: LocalEvalId,
392 pub title: String,
393 #[serde(skip_serializing_if = "Vec::is_empty")]
394 pub mutex: Vec<MutexTag>,
395 #[serde(flatten)]
396 body: EvalBody,
397}
398
399#[derive(Debug, Clone, Serialize)]
401#[serde(rename_all = "camelCase")]
402struct EvalBody {
403 profile: Profile,
404 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
405 profile_variants: BTreeMap<ProfileVariantName, Profile>,
406 payload: EvalPayload,
407 pass_schema: Option<std::sync::Arc<Map<String, Value>>>,
409 fail_schema: Option<std::sync::Arc<Map<String, Value>>>,
410}
411
412impl EvalDeclaration {
413 pub fn profile(&self) -> &Profile {
414 &self.body.profile
415 }
416 pub fn payload(&self) -> Option<&EvalPayload> {
417 Some(&self.body.payload)
418 }
419 pub fn profile_variants(&self) -> &BTreeMap<ProfileVariantName, Profile> {
420 &self.body.profile_variants
421 }
422 pub fn pass_schema(&self) -> Option<&Map<String, Value>> {
423 self.body.pass_schema.as_deref()
424 }
425 pub fn fail_schema(&self) -> Option<&Map<String, Value>> {
426 self.body.fail_schema.as_deref()
427 }
428 #[cfg(test)]
429 pub(crate) fn profile_mut(&mut self) -> &mut Profile {
430 &mut self.body.profile
431 }
432 #[cfg(test)]
433 pub(crate) fn set_pass_schema(&mut self, schema: Option<Map<String, Value>>) {
434 self.body.pass_schema = schema.map(std::sync::Arc::new);
435 }
436 #[cfg(test)]
437 pub(crate) fn set_fail_schema(&mut self, schema: Option<Map<String, Value>>) {
438 self.body.fail_schema = schema.map(std::sync::Arc::new);
439 }
440 pub(crate) fn select_profile(&mut self, name: &ProfileVariantName) -> Result<(), String> {
441 self.body.profile = self
442 .body
443 .profile_variants
444 .get(name)
445 .ok_or_else(|| format!("Unknown profile variant: {name}"))?
446 .clone();
447 Ok(())
448 }
449}
450
451#[derive(Deserialize)]
452#[serde(rename_all = "snake_case", deny_unknown_fields)]
453struct EvalFields {
454 title: String,
455 #[serde(default, deserialize_with = "present")]
456 mutex: Option<Vec<MutexTag>>,
457 profile: Profile,
458 #[serde(default, deserialize_with = "present")]
459 profile_variants: Option<BTreeMap<ProfileVariantName, Profile>>,
460 #[serde(default, deserialize_with = "present")]
461 payload: Option<EvalPayload>,
462 #[serde(default, deserialize_with = "present")]
463 pass_schema: Option<Map<String, Value>>,
464 #[serde(default, deserialize_with = "present")]
465 fail_schema: Option<Map<String, Value>>,
466}
467
468impl EvalDeclaration {
469 fn new(id: LocalEvalId, fields: EvalFields) -> Result<Self, String> {
471 if fields.mutex.is_some()
472 && !matches!(
473 fields.profile.kind(),
474 ProfileKind::Runtime | ProfileKind::Agent
475 )
476 {
477 return Err("mutex is supported only on runtime and agent Evals.".into());
478 }
479 let body = EvalBody {
480 profile: fields.profile,
481 profile_variants: fields.profile_variants.unwrap_or_default(),
482 payload: fields
483 .payload
484 .ok_or("Eval payload is required for runtime, agent and human profiles.")?,
485 pass_schema: fields.pass_schema.map(std::sync::Arc::new),
486 fail_schema: fields.fail_schema.map(std::sync::Arc::new),
487 };
488 Ok(Self {
489 id,
490 title: fields.title,
491 mutex: fields.mutex.unwrap_or_default(),
492 body,
493 })
494 }
495}
496
497impl EvalDeclaration {
498 fn validate(
499 &self,
500 location: &Location<'_, '_>,
501 schemas: &mut schemas::Schemas,
502 ) -> Result<(), String> {
503 location
504 .child("title")
505 .check(text(&self.title, "Eval title"))?;
506 if let Some(payload) = self.payload() {
507 location
508 .child("payload")
509 .child("instruction")
510 .check(text(&payload.instruction, "Eval payload.instruction"))?;
511 }
512 location
513 .child("mutex")
514 .check(mutex::validate(&self.mutex))?;
515 self.profile().validate(&location.child("profile"))?;
516 for (key, schema) in [
517 ("pass_schema", &self.body.pass_schema),
518 ("fail_schema", &self.body.fail_schema),
519 ] {
520 if let Some(schema) = schema {
521 location
522 .child(key)
523 .check(schemas.check(schema, crate::agent::verdict::validate_schema))?;
524 if self.profile().kind() == ProfileKind::Human {
526 location
527 .child(key)
528 .check(crate::review::check_schema(schema))?;
529 }
530 }
531 }
532 if self.profile_variants().len() > MAX_DECLARED_ITEMS {
533 return Err(location
534 .child("profile_variants")
535 .error("profile_variants must contain at most 64 named profiles."));
536 }
537 for (name, profile) in self.profile_variants() {
538 let variant = location.child("profile_variants").child(name);
539 location
540 .child("profile_variants")
541 .key(name)
542 .check(identifier(name, "Profile variant name"))?;
543 profile.validate(&variant)?;
544 if std::mem::discriminant(profile) != std::mem::discriminant(self.profile()) {
545 return Err(
546 variant.error("Profile variants must retain the declared reviewer kind.")
547 );
548 }
549 }
550 Ok(())
551 }
552}
553
554#[derive(Debug, Clone, Default, Serialize, Deserialize)]
555#[serde(
556 rename_all(serialize = "camelCase", deserialize = "snake_case"),
557 deny_unknown_fields
558)]
559pub struct Views {
560 #[serde(default)]
561 pub agent_sessions: BTreeMap<ToolOperationName, ToolSession>,
562 #[serde(default)]
563 pub agent_tools: BTreeMap<ToolOperationName, AgentTool>,
564 #[serde(default)]
565 pub human_tools: BTreeMap<ToolOperationName, HumanTool>,
566}
567
568impl Views {
569 fn validate(
570 &self,
571 location: &Location<'_, '_>,
572 schemas: &mut schemas::Schemas,
573 ) -> Result<(), String> {
574 for (name, session) in &self.agent_sessions {
575 let sessions = location.child("agent_sessions");
576 sessions
577 .key(name)
578 .check(identifier(name, "Tool session name"))?;
579 sessions
580 .child(name)
581 .child("start")
582 .check(session.start.validate())?;
583 sessions
584 .child(name)
585 .child("stop")
586 .check(session.stop.validate())?;
587 }
588 for (name, tool) in &self.agent_tools {
589 let tools = location.child("agent_tools");
590 if let AgentTool::Command(tool) = tool
591 && let Some(session) = &tool.session
592 && !self.agent_sessions.contains_key(session)
593 {
594 return Err(tools
595 .child(name)
596 .child("session")
597 .error(format!("Unknown tool session: {session}")));
598 }
599 tools.key(name).check(identifier(name, "Agent tool name"))?;
600 tools.child(name).check(
601 tool.validate(schemas)
602 .map_err(|e| format!("Tool {name}: {e}")),
603 )?;
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: crate::cache::IgnorePatterns,
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: crate::cache::IgnorePatterns,
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: Default::default(),
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 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": crate::platform::program_text(&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, .. } => {
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 Ok(())
794 }
795 }
796 }
797}
798
799#[derive(Debug, Clone, Deserialize)]
800#[serde(rename_all = "snake_case", deny_unknown_fields)]
801pub struct ArtifactDeclaration {
802 pub name: ArtifactName,
803 #[serde(default)]
804 pub tags: Vec<String>,
805 #[serde(default, deserialize_with = "declared_evals")]
806 pub evals: Vec<EvalDeclaration>,
807 #[serde(default)]
808 pub views: Views,
809 #[serde(default, deserialize_with = "present")]
810 pub basis: Option<bool>,
811 #[serde(
812 default = "default_fingerprint",
813 deserialize_with = "declared_fingerprint"
814 )]
815 pub fingerprint: Option<Fingerprint>,
816 #[serde(default, deserialize_with = "present")]
818 pub select: Option<Vec<LogicalPath>>,
819 #[serde(default, deserialize_with = "present")]
821 pub parts: Option<Vec<ArtifactName>>,
822}
823
824impl ArtifactDeclaration {
825 fn share(&mut self, schemas: &mut schemas::Schemas) {
827 for tool in self.views.agent_tools.values_mut() {
828 if let AgentTool::Command(tool) = tool {
829 tool.input_schema = schemas.tool(&tool.input_schema);
830 }
831 }
832 for eval in &mut self.evals {
833 for schema in [&mut eval.body.pass_schema, &mut eval.body.fail_schema]
834 .into_iter()
835 .flatten()
836 {
837 *schema = schemas.verdict(schema);
838 }
839 }
840 }
841
842 fn validate(
843 &self,
844 location: &Location<'_, '_>,
845 schemas: &mut schemas::Schemas,
846 ) -> Result<(), String> {
847 location
848 .child("name")
849 .check(artifact_name(&self.name, "Artifact name"))?;
850 location.child("tags").check(tags(&self.tags))?;
851 for eval in &self.evals {
852 eval.validate(&location.child("evals").key(&eval.id), schemas)
853 .map_err(|message| format!("Eval {}: {message}", eval.id))?;
854 }
855 if self.basis == Some(true) && !self.evals.is_empty() {
856 return Err(location
857 .child("basis")
858 .error(format!("Basis Artifact {} cannot own Evals.", self.name)));
859 }
860 self.views.validate(&location.child("views"), schemas)?;
861 if let Some(fingerprint) = &self.fingerprint {
862 fingerprint.validate(&location.child("fingerprint"))?;
863 }
864 if let Some(parts) = &self.parts {
865 let parts_location = location.child("parts");
866 if parts.is_empty()
867 || parts.len() > MAX_DECLARED_ITEMS
868 || parts.iter().collect::<BTreeSet<_>>().len() != parts.len()
869 {
870 return Err(parts_location.error("parts must name 1–64 unique Artifacts."));
871 }
872 if parts.contains(&self.name) {
873 return Err(
874 parts_location.error(format!("{} cannot be a part of itself.", self.name))
875 );
876 }
877 }
878 if let Some(select) = &self.select {
879 let select_location = location.child("select");
880 if select.is_empty()
881 || select.len() > MAX_DECLARED_ITEMS
882 || select.iter().collect::<BTreeSet<_>>().len() != select.len()
883 {
884 return Err(select_location
885 .error("select must contain 1–64 unique paths relative to the declaration."));
886 }
887 for selected in select {
888 select_location
889 .check(path(selected).map_err(|message| format!("select: {message}")))?;
890 if selected.ends_with(".artf") {
891 return Err(select_location.error(
892 "select: Artifact declarations (*.artf) are never part of an aspect.",
893 ));
894 }
895 }
896 }
897 Ok(())
898 }
899}
900
901fn declared_evals<'de, D: serde::Deserializer<'de>>(
903 deserializer: D,
904) -> Result<Vec<EvalDeclaration>, D::Error> {
905 use serde::de::Error;
906 BTreeMap::<String, EvalFields>::deserialize(deserializer)?
907 .into_iter()
908 .map(|(id, fields)| {
909 let id = id.parse().map_err(D::Error::custom)?;
910 EvalDeclaration::new(id, fields).map_err(D::Error::custom)
911 })
912 .collect()
913}
914
915pub fn parse_declaration(source: &str) -> Result<ArtifactDeclaration, String> {
917 parse_declaration_in(source, &mut schemas::Schemas::default())
918}
919
920fn parse_declaration_in(
922 source: &str,
923 schemas: &mut schemas::Schemas,
924) -> Result<ArtifactDeclaration, String> {
925 let table = toml::de::DeTable::parse(source).map_err(|error| error.to_string())?;
926 let span = table.span();
927 let document = toml::Spanned::new(span, toml::de::DeValue::Table(table.into_inner()));
928 let location = Location::root(source, &document);
929 for (key, value) in document.get_ref().as_table().unwrap() {
930 reject_non_json_values(value, key.get_ref().as_ref(), source)?;
931 }
932 if document.get_ref().get("family").is_some() {
933 return Err(location
934 .child("family")
935 .error("family was removed in 0.9.0; declare each instance as its own Artifact."));
936 }
937 for key in ["stale_key", "stale"] {
938 if document.get_ref().get(key).is_some() {
939 return Err(location.child(key).error(format!(
940 "{key} was renamed to fingerprint: use fingerprint = {{ files = [\".\"], ignore = [] }} or [fingerprint.script]."
941 )));
942 }
943 }
944 if let Some(evals) = document
945 .get_ref()
946 .get("evals")
947 .and_then(|value| value.get_ref().as_table())
948 {
949 for (id, eval) in evals {
950 if eval.get_ref().get("gate").is_some() {
951 return Err(location
952 .child("evals")
953 .child(id.get_ref())
954 .child("gate")
955 .error(format!(
956 "Eval {}: gate was removed in 0.16.0; remove it. A Human sign-off never holds up other evals.",
957 id.get_ref()
958 )));
959 }
960 if eval.get_ref().get("result_check").is_some() {
961 return Err(location
962 .child("evals")
963 .child(id.get_ref())
964 .child("result_check")
965 .error(format!(
966 "Eval {}: result_check was removed in 0.6.0; remove it. The review's tool calls are in its saved session (artifactize session show).",
967 id.get_ref()
968 )));
969 }
970 }
971 }
972 validate_identity_locations(&location, &document)?;
975 if let Some(fingerprint) = document.get_ref().get("fingerprint")
977 && let Some(fields) = fingerprint.get_ref().as_table()
978 {
979 let fingerprint_location = location.child("fingerprint");
980 if fields.contains_key("script") {
981 fingerprint_location.deserialize::<ScriptForm>()?;
982 } else if !fields.contains_key("dependencies") {
983 fingerprint_location.deserialize::<ArtifactsumForm>()?;
984 }
985 }
986 if let Some(tools) = document
987 .get_ref()
988 .get("views")
989 .and_then(|views| views.get_ref().get("agent_tools"))
990 .and_then(|tools| tools.get_ref().as_table())
991 {
992 for (name, tool) in tools {
993 if tool.get_ref().as_table().is_some() {
994 let tool_location = location
995 .child("views")
996 .child("agent_tools")
997 .child(name.get_ref());
998 if tool.get_ref().get("builtin").is_some() {
999 tool_location.deserialize::<BuiltinTool>()?;
1000 } else {
1001 tool_location.deserialize::<CommandTool>()?;
1002 }
1003 }
1004 }
1005 }
1006 if let Some(tools) = document
1007 .get_ref()
1008 .get("views")
1009 .and_then(|views| views.get_ref().get("human_tools"))
1010 .and_then(|tools| tools.get_ref().as_table())
1011 {
1012 for (name, _) in tools {
1013 location
1014 .child("views")
1015 .child("human_tools")
1016 .child(name.get_ref())
1017 .deserialize::<HumanTool>()?;
1018 }
1019 }
1020 if let Some(sessions) = document
1021 .get_ref()
1022 .get("views")
1023 .and_then(|views| views.get_ref().get("agent_sessions"))
1024 .and_then(|sessions| sessions.get_ref().as_table())
1025 {
1026 for (name, _) in sessions {
1027 location
1028 .child("views")
1029 .child("agent_sessions")
1030 .child(name.get_ref())
1031 .deserialize::<ToolSession>()?;
1032 }
1033 }
1034 let mut declaration =
1035 ArtifactDeclaration::deserialize(toml::de::ValueDeserializer::from(document.clone()))
1036 .map_err(|mut error| {
1037 error.set_input(Some(source));
1038 error.to_string()
1039 })?;
1040 declaration.share(schemas);
1041 declaration.validate(&location, schemas)?;
1042 Ok(declaration)
1043}
1044
1045fn validate_identity_locations(
1046 location: &Location<'_, '_>,
1047 document: &toml::Spanned<toml::de::DeValue<'_>>,
1048) -> Result<(), String> {
1049 use toml::de::DeValue;
1050 if let Some(name) = document.get_ref().get("name")
1051 && let DeValue::String(name) = name.get_ref()
1052 {
1053 location
1054 .child("name")
1055 .check(artifact_name(name, "Artifact name"))?;
1056 }
1057 if let Some(evals) = document
1058 .get_ref()
1059 .get("evals")
1060 .and_then(|value| value.get_ref().as_table())
1061 {
1062 for (id, eval) in evals {
1063 let eval_location = location.child("evals").key(id.get_ref());
1064 eval_location.check(identifier(id.get_ref(), "Eval id"))?;
1065 if let Some(model) = eval
1066 .get_ref()
1067 .get("profile")
1068 .and_then(|profile| profile.get_ref().get("model"))
1069 && let DeValue::String(model) = model.get_ref()
1070 {
1071 eval_location
1072 .child("profile")
1073 .child("model")
1074 .check(text(model, "Agent profile model"))?;
1075 }
1076 if let Some(variants) = eval
1077 .get_ref()
1078 .get("profile_variants")
1079 .and_then(|value| value.get_ref().as_table())
1080 {
1081 for (name, profile) in variants {
1082 let variant = eval_location.child("profile_variants").key(name.get_ref());
1083 variant.check(identifier(name.get_ref(), "Profile variant name"))?;
1084 if let Some(model) = profile.get_ref().get("model")
1085 && let DeValue::String(model) = model.get_ref()
1086 {
1087 variant
1088 .child("model")
1089 .check(text(model, "Agent profile model"))?;
1090 }
1091 }
1092 }
1093 }
1094 }
1095 Ok(())
1096}
1097
1098fn reject_non_json_values(
1100 value: &toml::Spanned<toml::de::DeValue<'_>>,
1101 path: &str,
1102 source: &str,
1103) -> Result<(), String> {
1104 use toml::de::DeValue;
1105 let diagnostic = |message| location::positioned(source, value.span().start, path, message);
1106 match value.get_ref() {
1107 DeValue::Datetime(_) => Err(diagnostic(
1108 "TOML date/time values have no JSON equivalent; use a quoted string.",
1109 )),
1110 DeValue::Float(number)
1111 if matches!(
1112 number.as_str(),
1113 "inf" | "+inf" | "-inf" | "nan" | "+nan" | "-nan"
1114 ) =>
1115 {
1116 Err(diagnostic(
1117 "TOML non-finite floats have no JSON equivalent.",
1118 ))
1119 }
1120 DeValue::Array(items) => {
1121 for (index, item) in items.iter().enumerate() {
1122 reject_non_json_values(item, &format!("{path}[{index}]"), source)?;
1123 }
1124 Ok(())
1125 }
1126 DeValue::Table(table) => {
1127 for (key, value) in table {
1128 reject_non_json_values(value, &format!("{path}.{}", key.get_ref()), source)?;
1129 }
1130 Ok(())
1131 }
1132 _ => Ok(()),
1133 }
1134}
1135
1136pub use artifactize_tools::scope::ArtifactKind;
1137
1138fn tool_nested(
1141 nested: &BTreeMap<ChildPrefix, ArtifactName>,
1142) -> BTreeMap<artifactize_tools::scope::ChildPrefix, artifactize_tools::scope::ArtifactId> {
1143 nested
1144 .iter()
1145 .map(|(path, name)| {
1146 (
1147 artifactize_tools::scope::ChildPrefix::new(path.as_ref())
1148 .expect("validated child prefix"),
1149 crate::scope::tool_id(name),
1150 )
1151 })
1152 .collect()
1153}
1154
1155#[derive(Debug, Clone, Serialize)]
1156#[serde(rename_all = "camelCase")]
1157pub struct Artifact {
1158 #[serde(with = "crate::platform::path_serde")]
1159 pub path: PathBuf,
1160 pub kind: ArtifactKind,
1161 pub nested: BTreeMap<ChildPrefix, ArtifactName>,
1164 #[serde(skip_serializing_if = "Vec::is_empty")]
1166 pub select: Vec<LogicalPath>,
1167 #[serde(skip_serializing_if = "BTreeSet::is_empty")]
1169 pub parts: BTreeSet<ArtifactName>,
1170 pub name: ArtifactName,
1171 pub tags: Vec<String>,
1172 pub views: Views,
1173 pub basis: Option<bool>,
1174 pub fingerprint: Option<Fingerprint>,
1175}
1176
1177impl Artifact {
1178 pub(crate) fn tool_scope(&self) -> artifactize_tools::scope::Artifact {
1179 artifactize_tools::scope::Artifact {
1180 path: self.path.clone(),
1181 kind: self.kind,
1182 name: self.name.to_string(),
1183 nested: tool_nested(&self.nested),
1184 select: self.select.iter().map(|path| path.to_string()).collect(),
1185 }
1186 }
1187
1188 pub fn folder(&self) -> &Path {
1190 folder_of(self.kind, &self.path)
1191 }
1192
1193 pub fn referenced_path(&self) -> &Path {
1196 if self.kind == ArtifactKind::Aspect {
1197 self.folder()
1198 } else {
1199 &self.path
1200 }
1201 }
1202
1203 pub fn file_name(&self) -> Option<&str> {
1204 (self.kind == ArtifactKind::File)
1205 .then(|| self.path.file_name().and_then(|name| name.to_str()))
1206 .flatten()
1207 }
1208
1209 pub fn declaration_path(&self) -> PathBuf {
1210 declaration_of(self.kind, &self.path)
1211 }
1212
1213 pub fn site(&self) -> Site {
1215 Site {
1216 kind: self.kind,
1217 path: self.path.clone(),
1218 }
1219 }
1220}
1221
1222#[derive(Debug, Serialize)]
1223pub struct Eval {
1224 pub id: EvalId,
1226 pub target: ArtifactName,
1227 pub references: BTreeMap<crate::config::ReferenceName, ArtifactName>,
1228 pub deps: Vec<ArtifactName>,
1229 pub declaration: EvalDeclaration,
1230 #[serde(skip)]
1233 pub variant: Option<ProfileVariantName>,
1234}
1235
1236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1237pub enum ReviewRequirement {
1238 Basis,
1239 Unreviewed,
1240 Evals,
1241 Parts,
1243}
1244
1245#[derive(Debug)]
1246pub struct RepoConfig {
1247 pub root: PathBuf,
1248 pub artifacts: BTreeMap<ArtifactName, Artifact>,
1249 pub evals: Vec<Eval>,
1250 pub relations: Vec<crate::scope::Relation>,
1251}
1252
1253impl RepoConfig {
1254 pub fn descendants<'a>(&'a self, name: &'a ArtifactName) -> Vec<&'a ArtifactName> {
1256 let mut seen = BTreeSet::new();
1257 let mut pending = vec![name];
1258 while let Some(next) = pending.pop() {
1259 for part in self.artifacts.get(next).into_iter().flat_map(|a| &a.parts) {
1260 if seen.insert(part) {
1261 pending.push(part);
1262 }
1263 }
1264 }
1265 seen.into_iter().collect()
1266 }
1267
1268 pub fn review_requirement(&self, artifact_id: &str) -> Option<ReviewRequirement> {
1270 let artifact = self.artifacts.get(artifact_id)?;
1271 Some(if artifact.basis == Some(true) {
1272 ReviewRequirement::Basis
1273 } else if self.evals.iter().any(|eval| eval.target == artifact_id) {
1274 ReviewRequirement::Evals
1275 } else if !artifact.parts.is_empty() {
1276 ReviewRequirement::Parts
1277 } else {
1278 ReviewRequirement::Unreviewed
1279 })
1280 }
1281}
1282
1283#[cfg(test)]
1284mod tests;