Skip to main content

machi_agent/
definition.rs

1//! Portable agent configuration.
2
3use machi_tools::CapabilityMode;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7/// Static or deferred instructions.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9#[serde(untagged)]
10#[non_exhaustive]
11pub enum Instructions {
12    /// Fixed system prompt body.
13    Static(String),
14}
15
16impl Instructions {
17    /// Resolve to a string.
18    #[must_use]
19    pub fn resolve(&self) -> String {
20        match self {
21            Self::Static(s) => s.clone(),
22        }
23    }
24}
25
26impl From<String> for Instructions {
27    fn from(value: String) -> Self {
28        Self::Static(value)
29    }
30}
31
32impl From<&str> for Instructions {
33    fn from(value: &str) -> Self {
34        Self::Static(value.to_owned())
35    }
36}
37
38/// Tool allow/deny policy on a definition (applied at agent resolution / build).
39#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
40#[non_exhaustive]
41pub enum ToolPolicy {
42    /// Inherit all tools supplied at build time.
43    #[default]
44    InheritAll,
45    /// Only these tool names.
46    Allowlist(Vec<String>),
47    /// All except these names.
48    Denylist(Vec<String>),
49}
50
51impl ToolPolicy {
52    /// Whether a tool name is admitted by this policy.
53    #[must_use]
54    pub fn admits(&self, name: &str) -> bool {
55        match self {
56            Self::InheritAll => true,
57            Self::Allowlist(allow) => allow.iter().any(|n| n == name),
58            Self::Denylist(deny) => !deny.iter().any(|n| n == name),
59        }
60    }
61}
62
63/// Require a tool call before the turn may complete.
64///
65/// Enforced by [`machi_runtime::TurnRuntime`] via stop gates: when the model
66/// returns a final message without having called `tool`, a reminder is injected
67/// and sampling continues (up to `max_retries`).
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct CompletionRequirement {
70    /// Canonical tool name that must be called.
71    pub tool: String,
72    /// Reminder injected when the model stops without calling it.
73    pub reminder: String,
74    /// Max forced re-samples.
75    pub max_retries: u32,
76}
77
78/// Where a definition was loaded from (for discovery precedence).
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
80#[serde(rename_all = "snake_case")]
81#[non_exhaustive]
82pub enum AgentSource {
83    /// Built-in catalogue (`general-purpose`, `explore`, `plan`, …).
84    Builtin,
85    /// User home `~/.machi/agents`.
86    User,
87    /// Project `.machi/agents` (cwd → repo root walk).
88    #[default]
89    Project,
90}
91
92/// Versionable agent definition (data only).
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct AgentDefinition {
95    /// Unique name (slug).
96    pub name: String,
97    /// Human description.
98    pub description: String,
99    /// Instructions / system prompt body.
100    pub instructions: Instructions,
101    /// Default model id.
102    pub model: String,
103    /// Tool policy (resolved at build time — definition-level `allowed_tools`).
104    #[serde(default)]
105    pub tools: ToolPolicy,
106    /// Optional structured output schema (JSON Schema object).
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub output_schema: Option<Value>,
109    /// Optional completion gate.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub completion: Option<CompletionRequirement>,
112    /// Default max steps for turns using this agent.
113    #[serde(default = "default_max_steps")]
114    pub max_steps: usize,
115    /// When false, definition is invisible and not callable.
116    #[serde(default = "default_enabled")]
117    pub enabled: bool,
118    /// Preferred capability mode (intersected with spawn request).
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub capability: Option<CapabilityMode>,
121    /// Discovery source (not required in markdown; set by resolver).
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub source: Option<AgentSource>,
124}
125
126fn default_max_steps() -> usize {
127    32
128}
129
130const fn default_enabled() -> bool {
131    true
132}
133
134impl AgentDefinition {
135    /// Minimal named definition with defaults.
136    #[must_use]
137    pub fn new(name: impl Into<String>) -> Self {
138        Self {
139            name: name.into(),
140            description: String::new(),
141            instructions: Instructions::Static(String::new()),
142            model: "default".into(),
143            tools: ToolPolicy::InheritAll,
144            output_schema: None,
145            completion: None,
146            max_steps: default_max_steps(),
147            enabled: true,
148            capability: None,
149            source: None,
150        }
151    }
152
153    /// Validate required fields.
154    ///
155    /// # Errors
156    ///
157    /// Returns [`machi_types::MachiError`] when name/model empty or `max_steps` is zero.
158    pub fn validate(&self) -> Result<(), machi_types::MachiError> {
159        use machi_types::{ErrorCode, MachiError};
160        if self.name.trim().is_empty() {
161            return Err(MachiError::new(
162                ErrorCode::AgentInvalidDefinition,
163                "agent name must be non-empty",
164            ));
165        }
166        if self.model.trim().is_empty() {
167            return Err(MachiError::new(
168                ErrorCode::AgentInvalidDefinition,
169                "agent model must be non-empty",
170            ));
171        }
172        if self.max_steps == 0 {
173            return Err(MachiError::new(
174                ErrorCode::AgentInvalidDefinition,
175                "max_steps must be >= 1",
176            ));
177        }
178        Ok(())
179    }
180}