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