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: Vec<String>,
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: Vec<String>,
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: Vec::new(),
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, ignore, .. } => {
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 location
784 .child("ignore")
785 .check(crate::cache::ignore_patterns(ignore))
786 }
787 }
788 }
789}
790
791#[derive(Debug, Clone, Deserialize)]
792#[serde(rename_all = "snake_case", deny_unknown_fields)]
793pub struct ArtifactDeclaration {
794 pub name: ArtifactName,
795 #[serde(default)]
796 pub tags: Vec<String>,
797 #[serde(default, deserialize_with = "declared_evals")]
798 pub evals: Vec<EvalDeclaration>,
799 #[serde(default)]
800 pub views: Views,
801 #[serde(default, deserialize_with = "present")]
802 pub basis: Option<bool>,
803 #[serde(
804 default = "default_fingerprint",
805 deserialize_with = "declared_fingerprint"
806 )]
807 pub fingerprint: Option<Fingerprint>,
808}
809
810impl ArtifactDeclaration {
811 fn share(&mut self, schemas: &mut schemas::Schemas) {
813 for tool in self.views.agent_tools.values_mut() {
814 if let AgentTool::Command(tool) = tool {
815 tool.input_schema = schemas.tool(&tool.input_schema);
816 }
817 }
818 for eval in &mut self.evals {
819 for schema in [&mut eval.body.pass_schema, &mut eval.body.fail_schema]
820 .into_iter()
821 .flatten()
822 {
823 *schema = schemas.verdict(schema);
824 }
825 }
826 }
827
828 fn validate(
829 &self,
830 location: &Location<'_, '_>,
831 schemas: &mut schemas::Schemas,
832 ) -> Result<(), String> {
833 location
834 .child("name")
835 .check(artifact_name(&self.name, "Artifact name"))?;
836 location.child("tags").check(tags(&self.tags))?;
837 for eval in &self.evals {
838 eval.validate(&location.child("evals").key(&eval.id), schemas)
839 .map_err(|message| format!("Eval {}: {message}", eval.id))?;
840 }
841 if self.basis == Some(true) && !self.evals.is_empty() {
842 return Err(location
843 .child("basis")
844 .error(format!("Basis Artifact {} cannot own Evals.", self.name)));
845 }
846 self.views.validate(&location.child("views"), schemas)?;
847 if let Some(fingerprint) = &self.fingerprint {
848 fingerprint.validate(&location.child("fingerprint"))?;
849 }
850 Ok(())
851 }
852}
853
854fn declared_evals<'de, D: serde::Deserializer<'de>>(
856 deserializer: D,
857) -> Result<Vec<EvalDeclaration>, D::Error> {
858 use serde::de::Error;
859 BTreeMap::<String, EvalFields>::deserialize(deserializer)?
860 .into_iter()
861 .map(|(id, fields)| {
862 let id = id.parse().map_err(D::Error::custom)?;
863 EvalDeclaration::new(id, fields).map_err(D::Error::custom)
864 })
865 .collect()
866}
867
868pub fn parse_declaration(source: &str) -> Result<ArtifactDeclaration, String> {
870 parse_declaration_in(source, &mut schemas::Schemas::default())
871}
872
873fn parse_declaration_in(
875 source: &str,
876 schemas: &mut schemas::Schemas,
877) -> Result<ArtifactDeclaration, String> {
878 let table = toml::de::DeTable::parse(source).map_err(|error| error.to_string())?;
879 let span = table.span();
880 let document = toml::Spanned::new(span, toml::de::DeValue::Table(table.into_inner()));
881 let location = Location::root(source, &document);
882 for (key, value) in document.get_ref().as_table().unwrap() {
883 reject_non_json_values(value, key.get_ref().as_ref(), source)?;
884 }
885 if document.get_ref().get("family").is_some() {
886 return Err(location
887 .child("family")
888 .error("family was removed in 0.9.0; declare each instance as its own Artifact."));
889 }
890 for key in ["stale_key", "stale"] {
891 if document.get_ref().get(key).is_some() {
892 return Err(location.child(key).error(format!(
893 "{key} was renamed to fingerprint: use fingerprint = {{ files = [\".\"], ignore = [] }} or [fingerprint.script]."
894 )));
895 }
896 }
897 if let Some(evals) = document
898 .get_ref()
899 .get("evals")
900 .and_then(|value| value.get_ref().as_table())
901 {
902 for (id, eval) in evals {
903 if eval.get_ref().get("gate").is_some() {
904 return Err(location
905 .child("evals")
906 .child(id.get_ref())
907 .child("gate")
908 .error(format!(
909 "Eval {}: gate was removed in 0.16.0; remove it. A Human sign-off never holds up other evals.",
910 id.get_ref()
911 )));
912 }
913 if eval.get_ref().get("result_check").is_some() {
914 return Err(location
915 .child("evals")
916 .child(id.get_ref())
917 .child("result_check")
918 .error(format!(
919 "Eval {}: result_check was removed in 0.6.0; remove it. The review's tool calls are in its saved session (artifactize session show).",
920 id.get_ref()
921 )));
922 }
923 }
924 }
925 validate_identity_locations(&location, &document)?;
928 if let Some(fingerprint) = document.get_ref().get("fingerprint")
930 && let Some(fields) = fingerprint.get_ref().as_table()
931 {
932 let fingerprint_location = location.child("fingerprint");
933 if fields.contains_key("script") {
934 fingerprint_location.deserialize::<ScriptForm>()?;
935 } else if !fields.contains_key("dependencies") {
936 fingerprint_location.deserialize::<ArtifactsumForm>()?;
937 }
938 }
939 if let Some(tools) = document
940 .get_ref()
941 .get("views")
942 .and_then(|views| views.get_ref().get("agent_tools"))
943 .and_then(|tools| tools.get_ref().as_table())
944 {
945 for (name, tool) in tools {
946 if tool.get_ref().as_table().is_some() {
947 let tool_location = location
948 .child("views")
949 .child("agent_tools")
950 .child(name.get_ref());
951 if tool.get_ref().get("builtin").is_some() {
952 tool_location.deserialize::<BuiltinTool>()?;
953 } else {
954 tool_location.deserialize::<CommandTool>()?;
955 }
956 }
957 }
958 }
959 if let Some(tools) = document
960 .get_ref()
961 .get("views")
962 .and_then(|views| views.get_ref().get("human_tools"))
963 .and_then(|tools| tools.get_ref().as_table())
964 {
965 for (name, _) in tools {
966 location
967 .child("views")
968 .child("human_tools")
969 .child(name.get_ref())
970 .deserialize::<HumanTool>()?;
971 }
972 }
973 if let Some(sessions) = document
974 .get_ref()
975 .get("views")
976 .and_then(|views| views.get_ref().get("agent_sessions"))
977 .and_then(|sessions| sessions.get_ref().as_table())
978 {
979 for (name, _) in sessions {
980 location
981 .child("views")
982 .child("agent_sessions")
983 .child(name.get_ref())
984 .deserialize::<ToolSession>()?;
985 }
986 }
987 let mut declaration =
988 ArtifactDeclaration::deserialize(toml::de::ValueDeserializer::from(document.clone()))
989 .map_err(|mut error| {
990 error.set_input(Some(source));
991 error.to_string()
992 })?;
993 declaration.share(schemas);
994 declaration.validate(&location, schemas)?;
995 Ok(declaration)
996}
997
998fn validate_identity_locations(
999 location: &Location<'_, '_>,
1000 document: &toml::Spanned<toml::de::DeValue<'_>>,
1001) -> Result<(), String> {
1002 use toml::de::DeValue;
1003 if let Some(name) = document.get_ref().get("name")
1004 && let DeValue::String(name) = name.get_ref()
1005 {
1006 location
1007 .child("name")
1008 .check(artifact_name(name, "Artifact name"))?;
1009 }
1010 if let Some(evals) = document
1011 .get_ref()
1012 .get("evals")
1013 .and_then(|value| value.get_ref().as_table())
1014 {
1015 for (id, eval) in evals {
1016 let eval_location = location.child("evals").key(id.get_ref());
1017 eval_location.check(identifier(id.get_ref(), "Eval id"))?;
1018 if let Some(model) = eval
1019 .get_ref()
1020 .get("profile")
1021 .and_then(|profile| profile.get_ref().get("model"))
1022 && let DeValue::String(model) = model.get_ref()
1023 {
1024 eval_location
1025 .child("profile")
1026 .child("model")
1027 .check(text(model, "Agent profile model"))?;
1028 }
1029 if let Some(variants) = eval
1030 .get_ref()
1031 .get("profile_variants")
1032 .and_then(|value| value.get_ref().as_table())
1033 {
1034 for (name, profile) in variants {
1035 let variant = eval_location.child("profile_variants").key(name.get_ref());
1036 variant.check(identifier(name.get_ref(), "Profile variant name"))?;
1037 if let Some(model) = profile.get_ref().get("model")
1038 && let DeValue::String(model) = model.get_ref()
1039 {
1040 variant
1041 .child("model")
1042 .check(text(model, "Agent profile model"))?;
1043 }
1044 }
1045 }
1046 }
1047 }
1048 Ok(())
1049}
1050
1051fn reject_non_json_values(
1053 value: &toml::Spanned<toml::de::DeValue<'_>>,
1054 path: &str,
1055 source: &str,
1056) -> Result<(), String> {
1057 use toml::de::DeValue;
1058 let diagnostic = |message| location::positioned(source, value.span().start, path, message);
1059 match value.get_ref() {
1060 DeValue::Datetime(_) => Err(diagnostic(
1061 "TOML date/time values have no JSON equivalent; use a quoted string.",
1062 )),
1063 DeValue::Float(number)
1064 if matches!(
1065 number.as_str(),
1066 "inf" | "+inf" | "-inf" | "nan" | "+nan" | "-nan"
1067 ) =>
1068 {
1069 Err(diagnostic(
1070 "TOML non-finite floats have no JSON equivalent.",
1071 ))
1072 }
1073 DeValue::Array(items) => {
1074 for (index, item) in items.iter().enumerate() {
1075 reject_non_json_values(item, &format!("{path}[{index}]"), source)?;
1076 }
1077 Ok(())
1078 }
1079 DeValue::Table(table) => {
1080 for (key, value) in table {
1081 reject_non_json_values(value, &format!("{path}.{}", key.get_ref()), source)?;
1082 }
1083 Ok(())
1084 }
1085 _ => Ok(()),
1086 }
1087}
1088
1089pub use artifactize_tools::scope::ArtifactKind;
1090
1091fn tool_children(
1094 children: &BTreeMap<ChildPrefix, ArtifactName>,
1095) -> BTreeMap<artifactize_tools::scope::ChildPrefix, artifactize_tools::scope::ArtifactId> {
1096 children
1097 .iter()
1098 .map(|(path, name)| {
1099 (
1100 artifactize_tools::scope::ChildPrefix::new(path.as_ref())
1101 .expect("validated child prefix"),
1102 crate::scope::tool_id(name),
1103 )
1104 })
1105 .collect()
1106}
1107
1108#[derive(Debug, Clone, Serialize)]
1109#[serde(rename_all = "camelCase")]
1110pub struct Artifact {
1111 #[serde(with = "crate::platform::path_serde")]
1112 pub path: PathBuf,
1113 pub kind: ArtifactKind,
1114 pub children: BTreeMap<ChildPrefix, ArtifactName>,
1115 pub name: ArtifactName,
1116 pub tags: Vec<String>,
1117 pub views: Views,
1118 pub basis: Option<bool>,
1119 pub fingerprint: Option<Fingerprint>,
1120}
1121
1122impl Artifact {
1123 pub(crate) fn tool_scope(&self) -> artifactize_tools::scope::Artifact {
1124 artifactize_tools::scope::Artifact {
1125 path: self.path.clone(),
1126 kind: self.kind,
1127 name: self.name.to_string(),
1128 children: tool_children(&self.children),
1129 }
1130 }
1131
1132 pub fn folder(&self) -> &Path {
1134 folder_of(self.kind, &self.path)
1135 }
1136
1137 pub fn file_name(&self) -> Option<&str> {
1138 (self.kind == ArtifactKind::File)
1139 .then(|| self.path.file_name().and_then(|name| name.to_str()))
1140 .flatten()
1141 }
1142
1143 pub fn declaration_path(&self) -> PathBuf {
1144 declaration_of(self.kind, &self.path)
1145 }
1146
1147 pub fn site(&self) -> Site {
1149 Site {
1150 kind: self.kind,
1151 path: self.path.clone(),
1152 }
1153 }
1154}
1155
1156#[derive(Debug, Serialize)]
1157pub struct Eval {
1158 pub id: EvalId,
1160 pub target: ArtifactName,
1161 pub references: BTreeMap<crate::config::ReferenceName, ArtifactName>,
1162 pub deps: Vec<ArtifactName>,
1163 pub declaration: EvalDeclaration,
1164 #[serde(skip)]
1167 pub variant: Option<ProfileVariantName>,
1168}
1169
1170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1171pub enum ReviewRequirement {
1172 Basis,
1173 Unreviewed,
1174 Evals,
1175}
1176
1177#[derive(Debug)]
1178pub struct RepoConfig {
1179 pub root: PathBuf,
1180 pub artifacts: BTreeMap<ArtifactName, Artifact>,
1181 pub evals: Vec<Eval>,
1182 pub relations: Vec<crate::scope::Relation>,
1183}
1184
1185impl RepoConfig {
1186 pub fn review_requirement(&self, artifact_id: &str) -> Option<ReviewRequirement> {
1188 let artifact = self.artifacts.get(artifact_id)?;
1189 Some(if artifact.basis == Some(true) {
1190 ReviewRequirement::Basis
1191 } else if self.evals.iter().any(|eval| eval.target == artifact_id) {
1192 ReviewRequirement::Evals
1193 } else {
1194 ReviewRequirement::Unreviewed
1195 })
1196 }
1197}
1198
1199#[cfg(test)]
1200mod tests;