Skip to content

Commit 8c6a4e3

Browse files
authored
Add artifact recipe adapter prototype (#88)
Add an artifact-capable CLI recipe adapter and route Matrix/Telegram replies through an internal outbound message envelope. Document the recipes/adapters/orchestrators direction and add Docker smoke coverage for npcsh, OmO/oh-my-opencode, and Gas Town CLI surfaces.
1 parent 91dc1b4 commit 8c6a4e3

12 files changed

Lines changed: 1131 additions & 31 deletions

File tree

crates/calciforge/src/adapters/artifact_cli.rs

Lines changed: 523 additions & 0 deletions
Large diffs are not rendered by default.

crates/calciforge/src/adapters/mod.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use std::fmt;
2222

2323
pub mod acp;
2424
pub mod acpx;
25+
pub mod artifact_cli;
2526
pub mod cli;
2627
pub mod codex_cli;
2728
pub mod dirac_cli;
@@ -35,6 +36,7 @@ pub mod zeroclaw_native;
3536

3637
pub use acp::AcpAdapter;
3738
pub use acpx::AcpxAdapter;
39+
pub use artifact_cli::ArtifactCliAdapter;
3840
pub use cli::CliAdapter;
3941
pub use codex_cli::CodexCliAdapter;
4042
pub use dirac_cli::DiracCliAdapter;
@@ -45,6 +47,7 @@ pub use zeroclaw::ZeroClawAdapter;
4547
pub use zeroclaw_native::ZeroClawNativeAdapter;
4648

4749
use crate::config::AgentConfig;
50+
use crate::messages::OutboundMessage;
4851

4952
// ---------------------------------------------------------------------------
5053
// Error type
@@ -161,6 +164,20 @@ pub trait AgentAdapter: Send + Sync {
161164
self.dispatch(ctx.message).await
162165
}
163166

167+
/// Dispatch and return a channel-agnostic outbound envelope.
168+
///
169+
/// Text-only adapters inherit this compatibility wrapper. Artifact-aware
170+
/// adapters override it so channels can render media natively where
171+
/// supported, or fall back to text paths elsewhere.
172+
async fn dispatch_message_with_context(
173+
&self,
174+
ctx: DispatchContext<'_>,
175+
) -> Result<OutboundMessage, AdapterError> {
176+
self.dispatch_with_context(ctx)
177+
.await
178+
.map(OutboundMessage::text)
179+
}
180+
164181
/// Short name for logs and `!agents` output (e.g. "openclaw-channel", "zeroclaw", "cli").
165182
fn kind(&self) -> &'static str;
166183

@@ -193,7 +210,7 @@ pub fn agent_supports_model_override(agent: &AgentConfig) -> bool {
193210

194211
matches!(
195212
agent.kind.as_str(),
196-
"zeroclaw-http" | "zeroclaw-native" | "zeroclaw" | "cli" | "codex-cli"
213+
"zeroclaw-http" | "zeroclaw-native" | "zeroclaw" | "cli" | "artifact-cli" | "codex-cli"
197214
)
198215
}
199216

@@ -216,6 +233,7 @@ pub fn agent_supports_model_override(agent: &AgentConfig) -> bool {
216233
/// | `zeroclaw-native` | `/webhook` + history | ✅ in-process ring buffer | ✅ |
217234
/// | `zeroclaw` | `/webhook` | per-ZeroClaw-config | n/a |
218235
/// | `cli` | subprocess stdin | ❌ one-shot | n/a |
236+
/// | `artifact-cli` | subprocess stdin + artifact dir | ❌ one-shot | n/a |
219237
/// | `codex-cli` | `codex exec` | ❌ one-shot | n/a |
220238
/// | `dirac-cli` | `dirac --yolo --json` | ❌ one-shot | n/a |
221239
/// | `acp` | SACP stdio | ✅ persistent proc | n/a |
@@ -316,6 +334,18 @@ pub fn build_adapter(agent: &AgentConfig) -> Result<Box<dyn AgentAdapter>, Strin
316334
agent.timeout_ms,
317335
)))
318336
}
337+
"artifact-cli" => {
338+
let command = agent.command.clone().ok_or_else(|| {
339+
format!("agent '{}': kind='artifact-cli' requires command", agent.id)
340+
})?;
341+
Ok(Box::new(ArtifactCliAdapter::new(
342+
command,
343+
agent.args.clone(),
344+
agent.env.clone().unwrap_or_default(),
345+
agent.model.clone(),
346+
agent.timeout_ms,
347+
)))
348+
}
319349
"codex-cli" => Ok(Box::new(CodexCliAdapter::new(
320350
agent.command.clone(),
321351
agent.args.clone(),
@@ -489,6 +519,15 @@ mod tests {
489519
assert_eq!(adapter.kind(), "cli");
490520
}
491521

522+
#[test]
523+
fn test_build_artifact_cli_adapter() {
524+
let mut agent = cli_agent();
525+
agent.id = "test-artifact-cli".to_string();
526+
agent.kind = "artifact-cli".to_string();
527+
let adapter = build_adapter(&agent).expect("should build artifact-cli adapter");
528+
assert_eq!(adapter.kind(), "artifact-cli");
529+
}
530+
492531
#[test]
493532
fn test_build_codex_cli_adapter() {
494533
let agent = AgentConfig {
@@ -722,6 +761,9 @@ mod tests {
722761
agent.kind = "cli".to_string();
723762
assert!(agent_supports_model_override(&agent));
724763

764+
agent.kind = "artifact-cli".to_string();
765+
assert!(agent_supports_model_override(&agent));
766+
725767
agent.kind = "acpx".to_string();
726768
assert!(
727769
!agent_supports_model_override(&agent),

crates/calciforge/src/channels/matrix.rs

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ use crate::{
2828
commands::CommandHandler,
2929
config::{expand_tilde, CalciforgeConfig},
3030
context::ContextStore,
31+
messages::OutboundMessage,
3132
router::Router,
3233
};
3334

@@ -323,6 +324,25 @@ async fn send_matrix_message(
323324
Ok(())
324325
}
325326

327+
async fn send_matrix_outbound_message(
328+
homeserver: &str,
329+
http: &reqwest::Client,
330+
auth_header: &str,
331+
room_id: &str,
332+
message: &OutboundMessage,
333+
) -> Result<()> {
334+
// First prototype: preserve attachments through the internal envelope and
335+
// render a text fallback. Native Matrix media upload can be added here.
336+
send_matrix_message(
337+
homeserver,
338+
http,
339+
auth_header,
340+
room_id,
341+
&message.render_text_fallback(),
342+
)
343+
.await
344+
}
345+
326346
async fn join_matrix_room(
327347
homeserver: &str,
328348
http: &reqwest::Client,
@@ -642,6 +662,34 @@ async fn handle_message(
642662
}
643663
}
644664
};
665+
let send_outbound = |message: OutboundMessage, reply_kind: &'static str| {
666+
let homeserver = homeserver.to_string();
667+
let http = http.clone();
668+
let auth_header = auth_header.to_string();
669+
let room_id = room_id.to_string();
670+
async move {
671+
let start = std::time::Instant::now();
672+
let response_len = message.response_len();
673+
match send_matrix_outbound_message(&homeserver, &http, &auth_header, &room_id, &message)
674+
.await
675+
{
676+
Ok(()) => telemetry::reply_sent(
677+
"matrix",
678+
&room_id,
679+
reply_kind,
680+
response_len,
681+
start.elapsed().as_millis() as u64,
682+
),
683+
Err(e) => telemetry::reply_failed(
684+
"matrix",
685+
&room_id,
686+
reply_kind,
687+
start.elapsed().as_millis() as u64,
688+
e,
689+
),
690+
}
691+
}
692+
};
645693

646694
// --- Command fast-path ---
647695
if let Some(reply) = cmd_handler.handle(body) {
@@ -767,7 +815,7 @@ async fn handle_message(
767815
let model_override = cmd_handler.active_model_for_identity(identity_id);
768816

769817
match router
770-
.dispatch_with_sender_and_model(
818+
.dispatch_message_with_sender_and_model(
771819
&augmented,
772820
&agent,
773821
config,
@@ -776,20 +824,22 @@ async fn handle_message(
776824
)
777825
.await
778826
{
779-
Ok(response) => {
827+
Ok(response_message) => {
828+
let response = response_message.render_text_fallback();
780829
let latency_ms = dispatch_start.elapsed().as_millis() as u64;
781830
cmd_handler.record_dispatch(latency_ms);
782831
telemetry::agent_dispatch_succeeded(
783832
"matrix",
784833
identity_id,
785834
&agent_id,
786835
latency_ms,
787-
response.len(),
836+
response_message.response_len(),
788837
);
789838
debug!(
790839
identity = %identity_id,
791840
agent_id = %agent_id,
792-
response_len = response.len(),
841+
response_len = response_message.response_len(),
842+
attachments = response_message.attachments.len(),
793843
"Matrix: got agent response"
794844
);
795845
ctx_store.push_with_options(
@@ -800,7 +850,7 @@ async fn handle_message(
800850
&response,
801851
preserve_native_commands,
802852
);
803-
send(response, "agent_response").await;
853+
send_outbound(response_message, "agent_response").await;
804854
}
805855
Err(e) => {
806856
// Clash approval flow

crates/calciforge/src/channels/telegram.rs

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use crate::{
1717
commands::CommandHandler,
1818
config::{expand_tilde, CalciforgeConfig},
1919
context::ContextStore,
20+
messages::OutboundMessage,
2021
router::Router,
2122
};
2223

@@ -478,7 +479,7 @@ fn handle_message_nonblocking(
478479

479480
let dispatch_start = std::time::Instant::now();
480481
match router
481-
.dispatch_with_sender_and_model(
482+
.dispatch_message_with_sender_and_model(
482483
&augmented_text,
483484
&agent,
484485
&config,
@@ -487,20 +488,22 @@ fn handle_message_nonblocking(
487488
)
488489
.await
489490
{
490-
Ok(response) => {
491+
Ok(response_message) => {
492+
let response = response_message.render_text_fallback();
491493
let latency_ms = dispatch_start.elapsed().as_millis() as u64;
492494
command_handler.record_dispatch(latency_ms);
493495
telemetry::agent_dispatch_succeeded(
494496
"telegram",
495497
&identity.id,
496498
&agent_id,
497499
latency_ms,
498-
response.len(),
500+
response_message.response_len(),
499501
);
500502
debug!(
501503
identity = %identity.id,
502504
agent_id = %agent_id,
503-
response_len = %response.len(),
505+
response_len = %response_message.response_len(),
506+
attachments = response_message.attachments.len(),
504507
"got agent response"
505508
);
506509

@@ -514,8 +517,7 @@ fn handle_message_nonblocking(
514517
preserve_native_commands,
515518
);
516519

517-
// Send response — try MarkdownV2 first, fall back to plain text.
518-
send_markdown_reply(bot, chat_id, response, "agent_response").await;
520+
send_outbound_reply(bot, chat_id, response_message, "agent_response").await;
519521
}
520522
Err(e) => {
521523
// ── Clash approval flow ─────────────────────────────────────
@@ -567,6 +569,18 @@ fn handle_message_nonblocking(
567569
});
568570
}
569571

572+
async fn send_outbound_reply(
573+
bot: Bot,
574+
chat_id: ChatId,
575+
reply: OutboundMessage,
576+
reply_kind: &'static str,
577+
) {
578+
// First prototype: preserve artifacts through the envelope and use the
579+
// text fallback for all Telegram replies. Native photo/document sending can
580+
// plug in here without changing adapter/orchestrator contracts.
581+
send_markdown_reply(bot, chat_id, reply.render_text_fallback(), reply_kind).await;
582+
}
583+
570584
async fn send_plain_reply(
571585
bot: Bot,
572586
chat_id: ChatId,

crates/calciforge/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ mod hooks;
1717
#[allow(dead_code)] // installer has production CLI entrypoints plus wizard/test support.
1818
mod install;
1919
mod local_model;
20+
mod messages;
2021
#[cfg(feature = "persistent-context")]
2122
mod persistent_context;
2223
mod providers;

0 commit comments

Comments
 (0)