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