procyon 0.1.1

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
//! The harness without the terminal.
//!
//! What Procyon *is* — the tool set, the workspace prompt, the gate in front of both — used to be
//! assembled inside `agent_task`, halfway down the TUI binary's own turn loop. Nothing else could
//! have it. That is a problem well before anyone wants a second front end: it means the thing the
//! benchmark is supposed to measure cannot be started without a pty, and that no test can exercise
//! the real tool set at all.
//!
//! So the assembly lives here, and both callers go through it: the TUI passes an [`Approver`] wired
//! to its channels, and [`run_once`] passes one that cannot ask anybody anything. The registry they
//! get is the same registry.

use std::sync::Arc;

use color_eyre::Result;

use crate::config::AppConfig;
use crate::tools::{self, approval::Approver, ToolRegistry};

/// A loaded tool set, plus what the caller should tell the user about how loading went.
pub struct Loaded {
    pub registry: ToolRegistry,
    /// One line per connected MCP server, naming its tools. Feeds the workspace prompt.
    pub mcp_connected: Vec<String>,
    /// Servers that could not be reached, or manifests that could not be read. Worth surfacing as
    /// errors: the model is about to be told those tools do not exist.
    pub problems: Vec<String>,
    /// Loading details that are not failures — a plugin count, a shadowed tool name.
    pub notices: Vec<String>,
}

/// Builds the tool set.
///
/// `approver` is `None` only where nothing needs gating, which in practice means a test: the
/// registry then runs whatever it is asked. Every path that can reach the user's files passes one.
pub async fn load_tools(cfg: &AppConfig, approver: Option<Arc<Approver>>) -> Loaded {
    let mut registry = match approver {
        Some(approver) => {
            // Recorded where a nested registry can find it: a specialist's tools are assembled
            // inside `talk_to`, which cannot see this call's channels.
            tools::approval::install(approver.clone());
            ToolRegistry::with_approver(approver)
        }
        None => ToolRegistry::new(),
    };

    registry.register(Box::new(tools::search::ListDirTool));
    registry.register(Box::new(tools::search::GlobTool));
    registry.register(Box::new(tools::search::GrepTool));
    registry.register(Box::new(tools::project::ProjectInitTool));
    registry.register(Box::new(tools::project::ProjectInfoTool));
    registry.register(Box::new(tools::caatinga::CaatingaBuildTool));
    registry.register(Box::new(tools::caatinga::CaatingaDeployTool));
    registry.register(Box::new(tools::file::ReadFileTool));
    registry.register(Box::new(tools::file::WriteFileTool));
    registry.register(Box::new(tools::file::EditFileTool));
    registry.register(Box::new(tools::invoke::CaatingaInvokeTool));
    registry.register(Box::new(tools::invoke::CaatingaReadTool));
    registry.register(Box::new(tools::invoke::StellarCliInvokeTool));
    registry.register(Box::new(tools::caatinga::CaatingaDoctorTool));
    registry.register(Box::new(tools::accounts::AccountCreateTool));
    registry.register(Box::new(tools::accounts::AccountListTool));
    registry.register(Box::new(tools::accounts::AccountBalanceTool));
    registry.register(Box::new(tools::test::RunTestsTool));
    registry.register(Box::new(tools::bindings::GenerateBindingsTool));
    registry.register(Box::new(tools::docs::GenerateDocsTool));
    registry.register(Box::new(tools::events::SubscribeEventsTool));
    registry.register(Box::new(tools::events::FilterEventsTool));
    registry.register(Box::new(tools::plugin::ListPluginsTool));
    registry.register(Box::new(tools::update::CheckUpdateTool));
    registry.register(Box::new(tools::skill::RunSkillTool));
    registry.register(Box::new(tools::skill::ListSkillsTool));
    registry.register(Box::new(tools::persona::TalkToTool));
    registry.register(Box::new(tools::persona::ListPersonasTool));
    registry.register(Box::new(tools::party::PartyModeTool));

    let mut problems = Vec::new();
    let mut notices = Vec::new();

    // Remote MCP tools are namespaced by server, and registered before plugins so a plugin
    // manifest cannot shadow one either.
    let (mcp_tools, mcp_connected, mcp_problems) = crate::mcp::load_servers(&cfg.mcp_servers).await;
    for tool in mcp_tools {
        if let Err(e) = registry.try_register(tool) {
            notices.push(format!("MCP {}", e));
        }
    }
    problems.extend(mcp_problems);

    // Registered after the builtins so a manifest cannot shadow one of them.
    let (plugin_tools, plugin_warnings) = tools::plugin::load_plugin_tools();
    let plugin_count = plugin_tools.len();
    let mut shadowed = 0;
    for tool in plugin_tools {
        if let Err(e) = registry.try_register(tool) {
            shadowed += 1;
            notices.push(format!("Plugin {}", e));
        }
    }
    if plugin_count > 0 {
        notices.push(format!("Loaded {} plugin tool(s)", plugin_count - shadowed));
    }
    notices.extend(plugin_warnings);

    Loaded {
        registry,
        mcp_connected,
        problems,
        notices,
    }
}

// The full registry's schemas alone were measured at ~4,358 estimated tokens for 30 tools —
// already past Ollama's 4,096-token ceiling (see `budget::context_window`) before a single
// message of conversation. This is the subset that keeps the core edit-build-deploy loop usable
// within that window; everything cut here (personas, party mode, spawn_agent, skills) is still
// reachable by switching to a provider with real headroom.
const OLLAMA_CORE_TOOLS: &[&str] = &[
    "list_dir",
    "glob",
    "grep",
    "project_init",
    "project_info",
    "read_file",
    "write_file",
    "edit_file",
    "caatinga_build",
    "caatinga_deploy",
    "caatinga_invoke",
    "caatinga_read",
    "caatinga_doctor",
    "stellar_invoke",
];

/// The tool definitions to actually offer this turn.
///
/// Computed per turn rather than once at boot: a live `/model provider ollama` switch has to
/// shrink what gets sent on the very next request, not only on a session that started that way.
pub fn tools_for_provider(
    provider: crate::config::Provider,
    tools: &[crate::agent::ToolDefinition],
) -> Vec<crate::agent::ToolDefinition> {
    if provider != crate::config::Provider::Ollama {
        return tools.to_vec();
    }
    tools
        .iter()
        .filter(|t| OLLAMA_CORE_TOOLS.contains(&t.name.as_str()))
        .cloned()
        .collect()
}

/// Builds the registry a specialist runs with.
///
/// Two filters, and the order does not matter because both only remove: the persona's own tool
/// list, and its risk ceiling. What comes out is the session's registry minus everything this
/// specialist may not use — so `SecurityAuditor` has no `write_file` to call rather than
/// instructions not to call it, and the difference between the specialists is operational instead
/// of stylistic.
///
/// The gate is the session's, not a fresh one. A specialist that writes still reaches the same
/// prompt the user answers, and a grant they already gave still counts; a new approver here would
/// either refuse every write (there is nobody on its channels) or, worse, gate nothing.
pub async fn persona_tools(cfg: &AppConfig, persona: &crate::personas::Persona) -> Loaded {
    let mut loaded = load_tools(cfg, tools::approval::session()).await;

    loaded.registry.retain(|tool| {
        if !tool.capability().within(persona.ceiling) {
            return false;
        }
        match &persona.tools {
            Some(allowed) => allowed.iter().any(|name| name == tool.name()),
            // A persona from disk that names no tools gets everything under its ceiling, which for
            // a persona that also named no ceiling is the read-only set.
            None => true,
        }
    });

    loaded
}

/// Everything one unattended turn did, for a caller that has to score it.
#[derive(Debug, Default)]
pub struct Run {
    pub text: String,
    /// One entry per tool call, in order, with the failure text when it failed.
    pub calls: Vec<(String, Option<String>)>,
}

impl Run {
    pub fn tool_calls(&self) -> usize {
        self.calls.len()
    }

    pub fn failed_calls(&self) -> usize {
        self.calls.iter().filter(|(_, err)| err.is_some()).count()
    }

    /// Calls that named a tool this harness does not have.
    ///
    /// The one unambiguous, machine-checkable hallucination: everything else a model gets wrong
    /// about Stellar needs a judgement call, but a tool that does not exist is not a matter of
    /// opinion. Counted separately from failures for exactly that reason.
    pub fn unsupported_calls(&self) -> usize {
        self.calls
            .iter()
            .filter(|(_, err)| {
                err.as_deref()
                    .is_some_and(|e| e.starts_with("Unknown tool:"))
            })
            .count()
    }
}

/// Collects [`Run::calls`] from the registry's own choke point.
#[derive(Default)]
struct CallLog {
    calls: std::sync::Mutex<Vec<(String, Option<String>)>>,
}

impl crate::tools::ToolObserver for CallLog {
    fn observe(&self, tool: &str, outcome: Result<(), &str>) {
        if let Ok(mut calls) = self.calls.lock() {
            calls.push((tool.to_string(), outcome.err().map(str::to_string)));
        }
    }
}

/// Runs one prompt to completion with no terminal attached, and returns what the model said.
///
/// For scripts, CI and the benchmark: the same tools, the same workspace prompt and the same gate
/// as an interactive turn, with nobody to answer a question. `allow_changes` is the only thing the
/// caller may relax, and it is deliberately coarse — an unattended run either may change things or
/// may not, because there is no third option when there is nobody to ask.
pub async fn run_once(cfg: &AppConfig, prompt: &str, allow_changes: bool) -> Result<Run> {
    let approver = Arc::new(Approver::unattended(allow_changes));
    let mut loaded = load_tools(cfg, Some(approver)).await;

    let calls = Arc::new(CallLog::default());
    loaded.registry.observe(calls.clone());

    // The same narrowing an interactive turn does. Without it a run against a local model died
    // before its first request: the full registry's schemas do not fit in Ollama's 4,096-token
    // window, and a sub-agent has no compaction to relieve it — the answer was "context window
    // full" after zero rounds, which reads as a broken harness rather than a tool list to trim.
    let allowed_tools: Vec<String> =
        tools_for_provider(cfg.provider, &loaded.registry.definitions())
            .into_iter()
            .map(|definition| definition.name)
            .collect();

    let cwd = std::env::current_dir()?;
    let context = crate::context::WorkspaceContext::gather(&cwd, &loaded.mcp_connected)
        .await
        .with_unverified(crate::verify::session().pending());

    let response = crate::agent::subagent::run_subagent(
        cfg,
        crate::agent::subagent::SubAgentConfig {
            system_prompt: context.system_prompt(),
            message: prompt.to_string(),
            model: None,
            max_tokens: None,
            max_rounds: None,
            allowed_tools: Some(allowed_tools),
            timeout_secs: None,
        },
        &loaded.registry,
    )
    .await?;

    Ok(Run {
        text: response.text,
        calls: calls
            .calls
            .lock()
            .map(|calls| calls.clone())
            .unwrap_or_default(),
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn config() -> AppConfig {
        AppConfig::default()
    }

    // The point of the extraction: the tool set can be built without a terminal, a channel or a
    // provider. Before this it could only be built halfway through the TUI's turn loop.
    #[tokio::test]
    async fn the_tool_set_loads_with_no_terminal_attached() {
        let loaded = load_tools(&config(), None).await;
        let names: Vec<String> = loaded
            .registry
            .definitions()
            .into_iter()
            .map(|d| d.name)
            .collect();

        for expected in [
            "read_file",
            "write_file",
            "caatinga_build",
            "caatinga_deploy",
            "run_tests",
            "account_list",
        ] {
            assert!(
                names.contains(&expected.to_string()),
                "missing {}",
                expected
            );
        }
    }

    // A registry the TUI would build and a registry a script would build must offer the model the
    // same tools, or the benchmark measures a harness nobody runs.
    #[tokio::test]
    async fn gating_the_registry_does_not_change_what_it_offers() {
        let ungated = load_tools(&config(), None).await;
        let gated = load_tools(&config(), Some(Arc::new(Approver::unattended(false)))).await;

        let names = |loaded: &Loaded| -> Vec<String> {
            let mut names: Vec<String> = loaded
                .registry
                .definitions()
                .into_iter()
                .map(|d| d.name)
                .collect();
            names.sort();
            names
        };
        assert_eq!(names(&ungated), names(&gated));
    }

    #[tokio::test]
    async fn a_config_with_no_servers_reports_no_problems() {
        let loaded = load_tools(&config(), None).await;
        assert!(loaded.mcp_connected.is_empty());
        assert!(loaded.problems.is_empty(), "{:?}", loaded.problems);
    }

    fn persona_with(tools: &[&str], ceiling: crate::risk::Capability) -> crate::personas::Persona {
        crate::personas::Persona {
            skill_name: "test-persona".to_string(),
            name: "TestPersona".to_string(),
            title: String::new(),
            icon: String::new(),
            role: String::new(),
            identity: String::new(),
            communication_style: String::new(),
            principles: Vec::new(),
            body: String::new(),
            when_to_use: String::new(),
            tools: Some(tools.iter().map(|t| t.to_string()).collect()),
            skills: Vec::new(),
            ceiling,
        }
    }

    // The bug this replaces: `talk_to` built a fresh, empty registry for every persona, so a
    // specialist could talk and nothing else — a system prompt with a name on it.
    #[tokio::test]
    async fn a_persona_s_registry_holds_only_what_it_asked_for() {
        let persona = persona_with(
            &["read_file", "caatinga_build"],
            crate::risk::Capability::Build,
        );
        let names: Vec<String> = persona_tools(&config(), &persona)
            .await
            .registry
            .definitions()
            .into_iter()
            .map(|d| d.name)
            .collect();

        assert_eq!(names.len(), 2, "{:?}", names);
        assert!(names.contains(&"read_file".to_string()));
        assert!(names.contains(&"caatinga_build".to_string()));
    }

    // The ceiling is structural: a persona that lists a tool above its own ceiling — a bug in the
    // persona's own definition, or a disk persona lying about its risk — must not get it anyway.
    #[tokio::test]
    async fn the_ceiling_wins_over_the_persona_s_own_list() {
        let persona = persona_with(
            &["read_file", "write_file", "caatinga_deploy"],
            crate::risk::Capability::ReadOnly,
        );
        let names: Vec<String> = persona_tools(&config(), &persona)
            .await
            .registry
            .definitions()
            .into_iter()
            .map(|d| d.name)
            .collect();

        assert_eq!(names, vec!["read_file".to_string()]);
    }

    // `None` — a disk persona that named no tools — gets everything under its ceiling, not nothing
    // and not everything the session has regardless of risk.
    #[tokio::test]
    async fn no_declared_tools_means_everything_under_the_ceiling() {
        let mut persona = persona_with(&[], crate::risk::Capability::ReadOnly);
        persona.tools = None;

        let names: Vec<String> = persona_tools(&config(), &persona)
            .await
            .registry
            .definitions()
            .into_iter()
            .map(|d| d.name)
            .collect();

        assert!(names.contains(&"read_file".to_string()));
        assert!(!names.contains(&"write_file".to_string()));
        assert!(!names.contains(&"caatinga_deploy".to_string()));
    }
}