Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/agent-runtime/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ impl WorkSpec {
self.context.as_deref() == Some("design")
}

/// Mirror of `delivery::ports::WorkSpec::to_prompt` (this crate deliberately
/// avoids depending on `delivery`); the two must stay in sync.
pub fn to_prompt(&self) -> String {
let mut p = String::new();
if let Some(instr) = &self.instructions {
Expand Down
22 changes: 2 additions & 20 deletions crates/delivery/src/adapters/local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,24 +46,6 @@ impl LocalCommandAgentExecutor {
}
}

fn spec_to_prompt(spec: &WorkSpec) -> String {
let mut p = String::new();
if let Some(instr) = &spec.instructions {
p.push_str(&format!("# Behavior (skills)\n{instr}\n\n"));
}
p.push_str(&format!("# Task: {}\n\n{}\n", spec.title, spec.description));
if !spec.acceptance_criteria.is_empty() {
p.push_str("\nAcceptance criteria:\n");
for c in &spec.acceptance_criteria {
p.push_str(&format!("- {c}\n"));
}
}
if let Some(ctx) = &spec.context {
p.push_str(&format!("\nContext: {ctx}\n"));
}
p
}

enum Line {
Event(NewExecutionEvent),
Result { reference: String, summary: String },
Expand Down Expand Up @@ -118,7 +100,7 @@ impl AgentExecutor for LocalCommandAgentExecutor {
.map_err(|e| ExecError::Backend(format!("spawn {program}: {e}")))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(spec_to_prompt(spec).as_bytes())
.write_all(spec.to_prompt().as_bytes())
.await
.map_err(|e| ExecError::Backend(e.to_string()))?;
}
Expand All @@ -139,7 +121,7 @@ impl AgentExecutor for LocalCommandAgentExecutor {

if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(spec_to_prompt(spec).as_bytes())
.write_all(spec.to_prompt().as_bytes())
.await
.map_err(|e| ExecError::Backend(e.to_string()))?;
// stdin drops here, sending EOF to the child (line reads would block forever otherwise)
Expand Down
6 changes: 3 additions & 3 deletions crates/delivery/src/adapters/queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use crate::ports::{
RuntimeInfo, WorkQueue, WorkSpec,
};

const KNOWN_CAPS: [ExecutorKind; 5] = ExecutorKind::ALL;
const KNOWN_CAPS: &[ExecutorKind] = ExecutorKind::ALL;

#[derive(Default)]
pub struct InMemoryWorkQueue {
Expand Down Expand Up @@ -94,8 +94,8 @@ impl WorkQueue for InMemoryWorkQueue {
async fn stats(&self) -> Vec<QueueStat> {
let q = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
KNOWN_CAPS
.into_iter()
.map(|k| QueueStat {
.iter()
.map(|&k| QueueStat {
executor: k,
ready: q.iter().filter(|s| s.executor == k).count() as u64,
in_flight: 0,
Expand Down
12 changes: 6 additions & 6 deletions crates/delivery/src/adapters/redis_queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const ACKMAP: &str = "fleet:ackmap";
const RT_INDEX: &str = "fleet:rt:index";
const SEP: char = '\u{1f}';

fn known_caps() -> [ExecutorKind; 5] {
fn known_caps() -> &'static [ExecutorKind] {
ExecutorKind::ALL
}

Expand Down Expand Up @@ -71,7 +71,7 @@ impl RedisStreamQueue {
let client = redis::Client::open(url)?;
let mut conn = client.get_multiplexed_async_connection().await?;
for cap in known_caps() {
let key = stream_key(cap);
let key = stream_key(*cap);
// Existing group yields BUSYGROUP; ignore for idempotency.
let res: redis::RedisResult<()> = redis::cmd("XGROUP")
.arg("CREATE")
Expand Down Expand Up @@ -174,7 +174,7 @@ impl WorkQueue for RedisStreamQueue {
let grace_ms = grace.as_millis() as usize;
let mut conn = self.conn.clone();
let mut requeued = 0usize;
let mut keys: Vec<String> = known_caps().into_iter().map(stream_key).collect();
let mut keys: Vec<String> = known_caps().iter().map(|&cap| stream_key(cap)).collect();
let rt_names: Vec<String> = conn.smembers(RT_INDEX).await.unwrap_or_default();
keys.extend(rt_names.iter().map(|n| rt_stream_key(n)));
for key in keys {
Expand Down Expand Up @@ -220,12 +220,12 @@ impl WorkQueue for RedisStreamQueue {
let mut conn = self.conn.clone();
let mut out = Vec::with_capacity(known_caps().len());
for cap in known_caps() {
let key = stream_key(cap);
let key = stream_key(*cap);
let groups: StreamInfoGroupsReply = match conn.xinfo_groups(&key).await {
Ok(g) => g,
Err(_) => {
out.push(QueueStat {
executor: cap,
executor: *cap,
ready: 0,
in_flight: 0,
oldest_in_flight_ms: 0,
Expand All @@ -246,7 +246,7 @@ impl WorkQueue for RedisStreamQueue {
} else {
0
};
out.push(QueueStat { executor: cap, ready, in_flight, oldest_in_flight_ms });
out.push(QueueStat { executor: *cap, ready, in_flight, oldest_in_flight_ms });
}
out
}
Expand Down
8 changes: 4 additions & 4 deletions crates/delivery/src/domain/delivery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ pub enum ExecutorKind {

impl ExecutorKind {
/// Single authoritative list of executor kinds; queue adapters and tests consume
/// it, so a new variant only touches this file.
pub const ALL: [ExecutorKind; 5] =
[Self::ClaudeCode, Self::Codex, Self::OpenCode, Self::CodeBuddy, Self::DeepCode];
/// it. A slice (not a fixed-size array) so adding a variant only touches this file.
pub const ALL: &[ExecutorKind] =
&[Self::ClaudeCode, Self::Codex, Self::OpenCode, Self::CodeBuddy, Self::DeepCode];

pub fn as_str(&self) -> &'static str {
match self {
Expand Down Expand Up @@ -267,7 +267,7 @@ mod tests {

#[test]
fn enum_str_roundtrips() {
for e in ExecutorKind::ALL {
for &e in ExecutorKind::ALL {
assert_eq!(ExecutorKind::parse(e.as_str()), Some(e));
}
assert_eq!(ExecutorKind::parse("X"), None);
Expand Down
54 changes: 54 additions & 0 deletions crates/delivery/src/ports/agent_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,31 @@ pub struct WorkSpec {
pub target_runtime: Option<String>,
}

impl WorkSpec {
/// Canonical prompt rendering for executor CLIs. `agent-runtime` keeps a
/// standalone mirror of this function (it deliberately avoids depending on
/// this crate); the two must stay in sync.
pub fn to_prompt(&self) -> String {
let mut p = String::new();
if let Some(instr) = &self.instructions {
p.push_str(&format!("# Behavior (skills)\n{instr}\n\n"));
}
p.push_str(&format!("# Task: {}\n\n{}\n", self.title, self.description));
if !self.acceptance_criteria.is_empty() {
p.push_str("\nAcceptance criteria:\n");
for c in &self.acceptance_criteria {
p.push_str(&format!("- {c}\n"));
}
}
if let Some(ctx) = &self.context {
if ctx != "design" {
p.push_str(&format!("\nContext: {ctx}\n"));
}
}
p
}
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DispatchOutcome {
Accepted { run_id: String },
Expand All @@ -41,3 +66,32 @@ pub trait AgentExecutor: Send + Sync {
sink: &dyn EventSink,
) -> Result<DispatchOutcome, ExecError>;
}

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

fn spec() -> WorkSpec {
WorkSpec {
attempt_id: "a".into(),
decomposition_id: "d".into(),
task_id: "t".into(),
title: "login".into(),
description: "do it".into(),
acceptance_criteria: vec!["c1".into()],
executor: ExecutorKind::ClaudeCode,
context: Some("design".into()),
instructions: Some("be careful".into()),
target_runtime: None,
}
}

#[test]
fn prompt_renders_instructions_task_criteria_and_skips_design_context() {
let p = spec().to_prompt();
assert!(p.contains("# Behavior (skills)\nbe careful"));
assert!(p.contains("# Task: login"));
assert!(p.contains("- c1"));
assert!(!p.contains("Context: design"));
}
}
8 changes: 6 additions & 2 deletions crates/server/src/mcp_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ impl<S: Send + Sync> FromRequestParts<S> for SessionHeader {
}

use delivery::application::DeliveryService;
use delivery::domain::ExecutorKind;
use requirement::application::{CreateRequirementUseCase, RequirementCmdError, RequirementService};
use skill::application::{CreateSkillUseCase, SkillService};
use task::application::{BreakdownUseCase, CreateDecompositionUseCase, TaskService};
Expand Down Expand Up @@ -485,7 +486,7 @@ pub fn router(
.requires("TASK", "ADD"))
.tool(Tool::new(
"shepherd_dispatch_delivery",
"把任务派发给 AI 执行者(executor: CLAUDE_CODE | CODEX | OPENCODE | CODEBUDDY | DEEPCODE);自动驱动任务、过验证门并回灌验证。\
"把任务派发给 AI 执行者(executor 枚举见 schema);自动驱动任务、过验证门并回灌验证。\
可直接传 skillIds(+projectId)自动组合行为规范注入,无需先调 compose。",
obj(
json!({
Expand All @@ -494,7 +495,10 @@ pub fn router(
"title": { "type": "string" },
"description": { "type": "string" },
"acceptanceCriteria": { "type": "array", "items": { "type": "string" } },
"executor": { "type": "string", "enum": ["CLAUDE_CODE", "CODEX", "OPENCODE", "CODEBUDDY", "DEEPCODE"] },
"executor": {
"type": "string",
"enum": ExecutorKind::ALL.iter().map(|k| json!(k.as_str())).collect::<Vec<_>>()
},
"projectId": { "type": "string", "description": "skillIds 非空时必填(用于组合)" },
"skillIds": { "type": "array", "items": { "type": "string" }, "description": "可选:自动组合这些 skill 为行为规范" },
"instructions": { "type": "string", "description": "可选:显式行为规范(与 skillIds 组合结果合并)" }
Expand Down
1 change: 1 addition & 0 deletions crates/shepherd-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ pub fn scaffold_files() -> Vec<(&'static str, &'static str)> {
]
}

// Keep in sync with delivery::ExecutorKind::ALL (crates/delivery/src/domain/delivery.rs).
pub fn normalize_agent(t: &str) -> R<String> {
match t.to_ascii_lowercase().replace('-', "_").as_str() {
"claude_code" => Ok("CLAUDE_CODE".into()),
Expand Down
5 changes: 3 additions & 2 deletions web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1148,7 +1148,8 @@ export interface DeliveryEvent {
detail?: unknown
}

/** Dispatchable executor type → display name (shared by task-center filter and dispatch picker). */
/** Dispatchable executor type → display name (shared by task-center filter and dispatch picker).
* Keep in sync with delivery::ExecutorKind::ALL (crates/delivery/src/domain/delivery.rs). */
export const EXECUTOR_LABEL: Record<string, string> = {
CLAUDE_CODE: 'Claude Code',
CODEX: 'Codex',
Expand All @@ -1167,7 +1168,7 @@ export interface TaskCenterItem {
description: string
/** Module (title of the owning requirement; empty string when none). */
module: string
/** Executor: CLAUDE_CODE / CODEX / OPENCODE / CODEBUDDY / DEEPCODE. */
/** Executor kind; display label via EXECUTOR_LABEL (kept in sync with the server enum). */
executor: string
/** Target runtime name for directed dispatch; empty = any runtime with the capability. */
targetRuntime?: string | null
Expand Down
Loading