@team-agent/installer 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +8 -0
- package/crates/team-agent/src/cli/diagnose.rs +52 -11
- package/crates/team-agent/src/cli/emit.rs +3 -2
- package/crates/team-agent/src/cli/mod.rs +225 -80
- package/crates/team-agent/src/cli/send.rs +1 -0
- package/crates/team-agent/src/cli/status_port.rs +135 -7
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +8 -1
- package/crates/team-agent/src/cli/tests/mod.rs +1 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +39 -0
- package/crates/team-agent/src/cli/types.rs +5 -1
- package/crates/team-agent/src/compiler/tests.rs +2 -2
- package/crates/team-agent/src/compiler.rs +1 -1
- package/crates/team-agent/src/coordinator/backoff.rs +57 -9
- package/crates/team-agent/src/coordinator/health.rs +65 -2
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +28 -16
- package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +87 -0
- package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/watch.rs +4 -2
- package/crates/team-agent/src/coordinator/tick.rs +195 -43
- package/crates/team-agent/src/leader/helpers.rs +2 -0
- package/crates/team-agent/src/leader/rediscover.rs +1 -0
- package/crates/team-agent/src/leader/start.rs +9 -1
- package/crates/team-agent/src/leader/takeover.rs +18 -1
- package/crates/team-agent/src/lifecycle/display.rs +3 -3
- package/crates/team-agent/src/lifecycle/launch.rs +772 -285
- package/crates/team-agent/src/lifecycle/mod.rs +1 -0
- package/crates/team-agent/src/lifecycle/profile_launch.rs +110 -4
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +4 -1
- package/crates/team-agent/src/lifecycle/restart/agent.rs +16 -5
- package/crates/team-agent/src/lifecycle/restart/common.rs +35 -25
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +31 -25
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests/core.rs +5 -5
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +4 -4
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +5 -3
- package/crates/team-agent/src/lifecycle/types.rs +4 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +361 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +2 -1
- package/crates/team-agent/src/mcp_server/tests/scoped.rs +14 -1
- package/crates/team-agent/src/mcp_server/tests/send.rs +15 -1
- package/crates/team-agent/src/mcp_server/tools.rs +65 -9
- package/crates/team-agent/src/mcp_server/wire.rs +2 -1
- package/crates/team-agent/src/message_store.rs +80 -0
- package/crates/team-agent/src/messaging/results.rs +76 -5
- package/crates/team-agent/src/messaging/send.rs +3 -1
- package/crates/team-agent/src/messaging/types.rs +15 -1
- package/crates/team-agent/src/messaging/watchers.rs +68 -30
- package/crates/team-agent/src/model/enums.rs +7 -1
- package/crates/team-agent/src/model/permissions.rs +7 -0
- package/crates/team-agent/src/model/spec.rs +3 -1
- package/crates/team-agent/src/provider/adapter.rs +472 -7
- package/crates/team-agent/src/provider/classify.rs +6 -2
- package/crates/team-agent/src/provider/faults.rs +3 -2
- package/crates/team-agent/src/provider/startup_prompt.rs +25 -7
- package/crates/team-agent/src/provider/types.rs +11 -0
- package/crates/team-agent/src/session_capture.rs +1 -0
- package/crates/team-agent/src/state/persist.rs +95 -19
- package/crates/team-agent/src/tmux_backend/tests.rs +8 -7
- package/crates/team-agent/src/tmux_backend.rs +134 -6
- package/crates/team-agent/src/transport.rs +32 -0
- package/package.json +4 -4
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
use std::path::Path;
|
|
2
|
+
|
|
3
|
+
use crate::lifecycle::types::{DangerousApproval, LifecycleError};
|
|
4
|
+
use crate::model::enums::{Enforcement, Provider};
|
|
5
|
+
use crate::model::ids::AgentId;
|
|
6
|
+
use crate::model::permissions::{resolve_permissions, AgentPermissionInput};
|
|
7
|
+
|
|
8
|
+
const RUNTIME_CONTRACT_SECTION: &str = r#"# Team Agent Teammate Runtime Contract
|
|
9
|
+
|
|
10
|
+
You are a teammate in a Team Agent runtime, not the user's primary assistant.
|
|
11
|
+
The user normally talks to the team lead. Plain text you write in this worker
|
|
12
|
+
session is local to this session and is not a team message.
|
|
13
|
+
|
|
14
|
+
Use Team Agent MCP tools for team-visible coordination:
|
|
15
|
+
- Send progress, blockers, permission needs, tool failures, scope changes, and
|
|
16
|
+
long-running status updates with team_orchestrator.send_message(to='leader',
|
|
17
|
+
content='<short message>').
|
|
18
|
+
- Send to another teammate by agent id when coordination is useful, or use
|
|
19
|
+
to='*' to notify every other team member. The runtime resolves only this team
|
|
20
|
+
and excludes your own worker.
|
|
21
|
+
- When the task is complete, call team_orchestrator.report_result exactly once.
|
|
22
|
+
- Do not pass sender, task_id, agent_id, schema_version, or ack fields unless
|
|
23
|
+
doing a low-level compatibility diagnostic. The MCP runtime fills protocol
|
|
24
|
+
fields from the current worker and task state.
|
|
25
|
+
|
|
26
|
+
If you are blocked or cannot continue, message the leader promptly instead of
|
|
27
|
+
waiting silently. If work takes several minutes, send a short progress update.
|
|
28
|
+
|
|
29
|
+
When any Team Agent worker hits a 500/529/rate-limit/overloaded API error,
|
|
30
|
+
slow the team down before retrying: wait 1-2 minutes, keep active workers low,
|
|
31
|
+
and avoid blind immediate retries."#;
|
|
32
|
+
|
|
33
|
+
const RESULT_ENVELOPE_OUTPUT_CONTRACT: &str =
|
|
34
|
+
"For progress or blockers, call team_orchestrator.send_message(to='leader', content='<short message>'); \
|
|
35
|
+
for teammate coordination, send to another agent id or to='*' for every other team member. \
|
|
36
|
+
do not pass sender, task_id, or requires_ack because the MCP runtime fills protocol fields. \
|
|
37
|
+
the runtime injects it into the attached Codex leader pane when the leader has run attach-leader. \
|
|
38
|
+
If no leader is attached, the tool returns a fallback/failed result instead of completion. \
|
|
39
|
+
Final completion must call team_orchestrator.report_result exactly once with a short summary \
|
|
40
|
+
and optional status/changes/tests; MCP fills schema_version, task_id, and agent_id.";
|
|
41
|
+
|
|
42
|
+
pub(crate) struct WorkerCommandAgent {
|
|
43
|
+
id: Option<String>,
|
|
44
|
+
provider: Provider,
|
|
45
|
+
role: Option<String>,
|
|
46
|
+
declared_tools: Option<Vec<String>>,
|
|
47
|
+
system_prompt_inline: Option<String>,
|
|
48
|
+
system_prompt_file: Option<String>,
|
|
49
|
+
output_contract_format: Option<String>,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
impl WorkerCommandAgent {
|
|
53
|
+
pub(crate) fn from_yaml(
|
|
54
|
+
agent: &crate::model::yaml::Value,
|
|
55
|
+
fallback_id: Option<&str>,
|
|
56
|
+
provider: Provider,
|
|
57
|
+
) -> Self {
|
|
58
|
+
let system_prompt = agent.get("system_prompt");
|
|
59
|
+
Self {
|
|
60
|
+
id: agent
|
|
61
|
+
.get("id")
|
|
62
|
+
.and_then(crate::model::yaml::Value::as_str)
|
|
63
|
+
.or(fallback_id)
|
|
64
|
+
.map(str::to_string),
|
|
65
|
+
provider,
|
|
66
|
+
role: agent
|
|
67
|
+
.get("role")
|
|
68
|
+
.and_then(crate::model::yaml::Value::as_str)
|
|
69
|
+
.map(str::to_string),
|
|
70
|
+
declared_tools: agent
|
|
71
|
+
.get("tools")
|
|
72
|
+
.and_then(crate::model::yaml::Value::as_list)
|
|
73
|
+
.map(|items| {
|
|
74
|
+
items
|
|
75
|
+
.iter()
|
|
76
|
+
.filter_map(crate::model::yaml::Value::as_str)
|
|
77
|
+
.map(str::to_string)
|
|
78
|
+
.collect()
|
|
79
|
+
}),
|
|
80
|
+
system_prompt_inline: system_prompt
|
|
81
|
+
.and_then(|prompt| prompt.get("inline"))
|
|
82
|
+
.and_then(crate::model::yaml::Value::as_str)
|
|
83
|
+
.filter(|value| !value.is_empty())
|
|
84
|
+
.map(str::to_string),
|
|
85
|
+
system_prompt_file: system_prompt
|
|
86
|
+
.and_then(|prompt| prompt.get("file"))
|
|
87
|
+
.filter(|value| value.is_truthy())
|
|
88
|
+
.and_then(crate::model::yaml::Value::as_str)
|
|
89
|
+
.map(str::to_string),
|
|
90
|
+
output_contract_format: agent
|
|
91
|
+
.get("output_contract")
|
|
92
|
+
.and_then(|contract| contract.get("format"))
|
|
93
|
+
.and_then(crate::model::yaml::Value::as_str)
|
|
94
|
+
.map(str::to_string),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
pub(crate) fn from_json(
|
|
99
|
+
agent: &serde_json::Value,
|
|
100
|
+
fallback_id: Option<&str>,
|
|
101
|
+
provider: Provider,
|
|
102
|
+
) -> Self {
|
|
103
|
+
let system_prompt = agent.get("system_prompt");
|
|
104
|
+
Self {
|
|
105
|
+
id: agent
|
|
106
|
+
.get("id")
|
|
107
|
+
.and_then(serde_json::Value::as_str)
|
|
108
|
+
.or(fallback_id)
|
|
109
|
+
.map(str::to_string),
|
|
110
|
+
provider,
|
|
111
|
+
role: agent
|
|
112
|
+
.get("role")
|
|
113
|
+
.and_then(serde_json::Value::as_str)
|
|
114
|
+
.map(str::to_string),
|
|
115
|
+
declared_tools: agent
|
|
116
|
+
.get("tools")
|
|
117
|
+
.and_then(serde_json::Value::as_array)
|
|
118
|
+
.map(|items| {
|
|
119
|
+
items
|
|
120
|
+
.iter()
|
|
121
|
+
.filter_map(serde_json::Value::as_str)
|
|
122
|
+
.map(str::to_string)
|
|
123
|
+
.collect()
|
|
124
|
+
}),
|
|
125
|
+
system_prompt_inline: system_prompt
|
|
126
|
+
.and_then(|prompt| prompt.get("inline"))
|
|
127
|
+
.and_then(serde_json::Value::as_str)
|
|
128
|
+
.filter(|value| !value.is_empty())
|
|
129
|
+
.map(str::to_string),
|
|
130
|
+
system_prompt_file: system_prompt
|
|
131
|
+
.and_then(|prompt| prompt.get("file"))
|
|
132
|
+
.and_then(serde_json::Value::as_str)
|
|
133
|
+
.filter(|value| !value.is_empty())
|
|
134
|
+
.map(str::to_string),
|
|
135
|
+
output_contract_format: agent
|
|
136
|
+
.get("output_contract")
|
|
137
|
+
.and_then(|contract| contract.get("format"))
|
|
138
|
+
.and_then(serde_json::Value::as_str)
|
|
139
|
+
.map(str::to_string),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
pub(crate) fn compile_worker_system_prompt(
|
|
145
|
+
agent: &WorkerCommandAgent,
|
|
146
|
+
) -> Result<String, LifecycleError> {
|
|
147
|
+
// Python prompt.py:39 — chunks = [identity, TEAMMATE_SYSTEM_PROMPT, ...]: the worker
|
|
148
|
+
// identity line anchors the very first section (live Python worker argv confirms).
|
|
149
|
+
// C-1 cr verdict / B2 灵魂件 — identity 必须 FIRST(MUST-4 行为层守:空白上下文问
|
|
150
|
+
// "你是谁"必须先答 Team Agent worker 身份)。runtime contract 跟后。
|
|
151
|
+
let mut chunks = vec![
|
|
152
|
+
identity_section(agent),
|
|
153
|
+
runtime_contract_section(),
|
|
154
|
+
role_body(agent)?,
|
|
155
|
+
];
|
|
156
|
+
if let Some(contract) = output_contract(agent) {
|
|
157
|
+
chunks.push(contract);
|
|
158
|
+
}
|
|
159
|
+
if let Some(notes) = permission_notes(agent)? {
|
|
160
|
+
chunks.push(notes);
|
|
161
|
+
}
|
|
162
|
+
Ok(chunks
|
|
163
|
+
.into_iter()
|
|
164
|
+
.filter(|chunk| !chunk.is_empty())
|
|
165
|
+
.collect::<Vec<_>>()
|
|
166
|
+
.join("\n\n"))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
pub(crate) fn resolved_tool_strings_for_command(
|
|
170
|
+
agent: &WorkerCommandAgent,
|
|
171
|
+
provider: Provider,
|
|
172
|
+
safety: &DangerousApproval,
|
|
173
|
+
) -> Result<Vec<String>, LifecycleError> {
|
|
174
|
+
let mut tools: Vec<String> = resolve_agent_permissions(agent, provider)?
|
|
175
|
+
.sorted_tool_strings()
|
|
176
|
+
.into_iter()
|
|
177
|
+
.map(str::to_string)
|
|
178
|
+
.collect();
|
|
179
|
+
if safety.enabled && !tools.iter().any(|tool| tool == "dangerous_auto_approve") {
|
|
180
|
+
tools.push("dangerous_auto_approve".to_string());
|
|
181
|
+
}
|
|
182
|
+
Ok(tools)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
fn resolve_agent_permissions(
|
|
186
|
+
agent: &WorkerCommandAgent,
|
|
187
|
+
provider: Provider,
|
|
188
|
+
) -> Result<crate::model::permissions::ResolvedPermissions, LifecycleError> {
|
|
189
|
+
resolve_permissions(&AgentPermissionInput {
|
|
190
|
+
id: agent.id.as_deref().map(AgentId::new),
|
|
191
|
+
provider,
|
|
192
|
+
role: agent.role.clone(),
|
|
193
|
+
tools: agent.declared_tools.clone(),
|
|
194
|
+
})
|
|
195
|
+
.map_err(|e| LifecycleError::Compile(e.to_string()))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
fn runtime_contract_section() -> String {
|
|
199
|
+
RUNTIME_CONTRACT_SECTION.to_string()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
fn identity_section(agent: &WorkerCommandAgent) -> String {
|
|
203
|
+
format!(
|
|
204
|
+
"You are Team Agent worker `{}` with role `{}`. When asked about your role or identity, answer with this Team Agent worker identity first, not only the generic provider product identity.",
|
|
205
|
+
agent.id.as_deref().unwrap_or("unknown"),
|
|
206
|
+
agent.role.as_deref().unwrap_or("developer")
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
fn role_body(agent: &WorkerCommandAgent) -> Result<String, LifecycleError> {
|
|
211
|
+
let mut chunks = Vec::new();
|
|
212
|
+
if let Some(inline) = &agent.system_prompt_inline {
|
|
213
|
+
chunks.push(inline.clone());
|
|
214
|
+
}
|
|
215
|
+
if let Some(path) = &agent.system_prompt_file {
|
|
216
|
+
let body = std::fs::read_to_string(Path::new(path))
|
|
217
|
+
.map_err(|e| LifecycleError::Compile(format!("read system_prompt.file {path}: {e}")))?;
|
|
218
|
+
if !body.is_empty() {
|
|
219
|
+
chunks.push(body);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
Ok(chunks.join("\n\n"))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
fn output_contract(agent: &WorkerCommandAgent) -> Option<String> {
|
|
226
|
+
(agent.output_contract_format.as_deref() == Some("result_envelope_v1"))
|
|
227
|
+
.then(|| RESULT_ENVELOPE_OUTPUT_CONTRACT.to_string())
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
fn permission_notes(agent: &WorkerCommandAgent) -> Result<Option<String>, LifecycleError> {
|
|
231
|
+
let permissions = resolve_agent_permissions(agent, agent.provider)?;
|
|
232
|
+
// C-2-1/C-2-2 cr verdict — Copilot 一期 framework 不替决 fs_read/fs_list/git_diff/
|
|
233
|
+
// provider_builtin(provider prompt 控);为诚实(MUST-NOT-13)在 system prompt 内
|
|
234
|
+
// 总是声明这些 provider-level prompt_only 工具,即便角色未显式声明。
|
|
235
|
+
let provider_prompt_only_extras = provider_default_prompt_only_tools(agent.provider);
|
|
236
|
+
let mut prompt_only: std::collections::BTreeSet<String> = permissions
|
|
237
|
+
.resolved_tools
|
|
238
|
+
.iter()
|
|
239
|
+
.filter(|tool| tool.enforcement == Enforcement::PromptOnly)
|
|
240
|
+
.filter_map(|tool| serde_json::to_value(tool.tool).ok())
|
|
241
|
+
.filter_map(|value| value.as_str().map(str::to_string))
|
|
242
|
+
.collect();
|
|
243
|
+
for tool in provider_prompt_only_extras {
|
|
244
|
+
prompt_only.insert((*tool).to_string());
|
|
245
|
+
}
|
|
246
|
+
if prompt_only.is_empty() {
|
|
247
|
+
return Ok(None);
|
|
248
|
+
}
|
|
249
|
+
let prompt_only: Vec<String> = prompt_only.into_iter().collect();
|
|
250
|
+
Ok(Some(format!(
|
|
251
|
+
"Permission note: these tools are prompt-only for this provider and not hard-enforced: {}",
|
|
252
|
+
prompt_only.join(", ")
|
|
253
|
+
)))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/// C-2-1 cr verdict — provider-level prompt_only tools that the framework cannot
|
|
257
|
+
/// hard-enforce. The system prompt declares them so the worker is honest about
|
|
258
|
+
/// where consent gates actually live (provider prompt vs framework).
|
|
259
|
+
fn provider_default_prompt_only_tools(provider: Provider) -> &'static [&'static str] {
|
|
260
|
+
match provider {
|
|
261
|
+
// C-2-1: fs_read / fs_list / git_diff / provider_builtin 由 provider prompt
|
|
262
|
+
// 控制,framework 不替决(prompt_only 诚实声明)。
|
|
263
|
+
Provider::Copilot => &["fs_list", "fs_read", "git_diff", "provider_builtin"],
|
|
264
|
+
_ => &[],
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#[cfg(test)]
|
|
269
|
+
mod tests {
|
|
270
|
+
use super::*;
|
|
271
|
+
use crate::lifecycle::types::DangerousApprovalSource;
|
|
272
|
+
|
|
273
|
+
fn disabled_safety() -> DangerousApproval {
|
|
274
|
+
DangerousApproval {
|
|
275
|
+
enabled: false,
|
|
276
|
+
source: DangerousApprovalSource::Disabled,
|
|
277
|
+
inherited: false,
|
|
278
|
+
provider: None,
|
|
279
|
+
flag: None,
|
|
280
|
+
worker_capability_above_leader: false,
|
|
281
|
+
ancestry_binary_name: None,
|
|
282
|
+
unexpected_binary: false,
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#[test]
|
|
287
|
+
fn empty_tools_use_role_defaults_and_aliases_resolve_before_command() {
|
|
288
|
+
let agent = WorkerCommandAgent {
|
|
289
|
+
id: Some("dev".to_string()),
|
|
290
|
+
provider: Provider::ClaudeCode,
|
|
291
|
+
role: Some("developer".to_string()),
|
|
292
|
+
declared_tools: Some(Vec::new()),
|
|
293
|
+
system_prompt_inline: None,
|
|
294
|
+
system_prompt_file: None,
|
|
295
|
+
output_contract_format: None,
|
|
296
|
+
};
|
|
297
|
+
let tools =
|
|
298
|
+
resolved_tool_strings_for_command(&agent, Provider::ClaudeCode, &disabled_safety())
|
|
299
|
+
.unwrap();
|
|
300
|
+
assert_eq!(
|
|
301
|
+
tools,
|
|
302
|
+
[
|
|
303
|
+
"execute_bash",
|
|
304
|
+
"fs_list",
|
|
305
|
+
"fs_read",
|
|
306
|
+
"fs_write",
|
|
307
|
+
"git_diff",
|
|
308
|
+
"mcp_team",
|
|
309
|
+
"provider_builtin"
|
|
310
|
+
]
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
let agent = WorkerCommandAgent {
|
|
314
|
+
declared_tools: Some(vec!["fs_*".to_string(), "@team-orchestrator".to_string()]),
|
|
315
|
+
..agent
|
|
316
|
+
};
|
|
317
|
+
let tools =
|
|
318
|
+
resolved_tool_strings_for_command(&agent, Provider::ClaudeCode, &disabled_safety())
|
|
319
|
+
.unwrap();
|
|
320
|
+
assert_eq!(tools, ["fs_list", "fs_read", "fs_write", "mcp_team"]);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#[test]
|
|
324
|
+
fn system_prompt_uses_identity_then_runtime_contract_python_order() {
|
|
325
|
+
// #264 D6: Python truth source (prompt.py:39, live ps confirmed) builds
|
|
326
|
+
// chunks = [identity, TEAMMATE_SYSTEM_PROMPT, role_body, output, permissions].
|
|
327
|
+
// The previous assertion locked the inverted contract-first order with no
|
|
328
|
+
// Python evidence; this is the corrected golden.
|
|
329
|
+
// 0.3.5 union (copilot v2 C-1 / B2 MUST-4 行为层守): identity 必须 FIRST —
|
|
330
|
+
// 空白上下文问"你是谁"的第一行答案必须先答 Team Agent worker 身份;
|
|
331
|
+
// Copilot 适配的 C-1-3 行为层要求与其它 provider 同步。
|
|
332
|
+
let agent = WorkerCommandAgent {
|
|
333
|
+
id: Some("coder".to_string()),
|
|
334
|
+
provider: Provider::Codex,
|
|
335
|
+
role: Some("Runtime Developer".to_string()),
|
|
336
|
+
declared_tools: Some(vec!["mcp_team".to_string()]),
|
|
337
|
+
system_prompt_inline: Some("Implement the assigned slice.".to_string()),
|
|
338
|
+
system_prompt_file: None,
|
|
339
|
+
output_contract_format: Some("result_envelope_v1".to_string()),
|
|
340
|
+
};
|
|
341
|
+
let prompt = compile_worker_system_prompt(&agent).unwrap();
|
|
342
|
+
assert!(
|
|
343
|
+
prompt.starts_with("You are Team Agent worker `coder` with role `Runtime Developer`."),
|
|
344
|
+
"compiled prompt must start with the identity section (Python prompt.py:39); head={:?}",
|
|
345
|
+
prompt.chars().take(120).collect::<String>()
|
|
346
|
+
);
|
|
347
|
+
let identity = prompt.find("worker `coder`").unwrap();
|
|
348
|
+
let runtime = prompt
|
|
349
|
+
.find(RUNTIME_CONTRACT_SECTION.lines().next().unwrap_or(""))
|
|
350
|
+
.unwrap();
|
|
351
|
+
let role = prompt.find("Implement the assigned slice.").unwrap();
|
|
352
|
+
let output = prompt
|
|
353
|
+
.find("Final completion must call team_orchestrator.report_result exactly once")
|
|
354
|
+
.unwrap();
|
|
355
|
+
let permissions = prompt.find("Permission note:").unwrap();
|
|
356
|
+
assert!(identity < runtime && runtime < role && role < output && output < permissions);
|
|
357
|
+
let slowdown_phrase = format!("500/{}", 500 + 29);
|
|
358
|
+
assert!(prompt.contains(&slowdown_phrase));
|
|
359
|
+
assert!(prompt.contains("Runtime Developer"));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
@@ -88,12 +88,13 @@ pub(crate) fn fork_agent(
|
|
|
88
88
|
as_agent_id: &str,
|
|
89
89
|
label: Option<&str>,
|
|
90
90
|
) -> ToolResult {
|
|
91
|
-
let _ = label;
|
|
92
91
|
let lifecycle_workspace = lifecycle_workspace(workspace, owner_team, false)?;
|
|
92
|
+
// operations.py:315 — the label becomes the forked agent's role.
|
|
93
93
|
let report = crate::lifecycle::launch::fork_agent(
|
|
94
94
|
&lifecycle_workspace,
|
|
95
95
|
&AgentId::new(source_agent_id),
|
|
96
96
|
&AgentId::new(as_agent_id),
|
|
97
|
+
label,
|
|
97
98
|
false,
|
|
98
99
|
owner_team.map(TeamKey::as_str),
|
|
99
100
|
)
|
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
#[test]
|
|
2
2
|
fn dispatch_send_message_worker_accepted_returned_verbatim() {
|
|
3
|
+
// A-7: accepted requires a REAL stored message_id (no fabricated ids), so the
|
|
4
|
+
// workspace seeds a running worker-1 the delivery layer can actually queue for.
|
|
5
|
+
let ws = unique_ws("dispatch-accepted");
|
|
6
|
+
crate::state::persist::save_runtime_state(
|
|
7
|
+
&ws,
|
|
8
|
+
&serde_json::json!({
|
|
9
|
+
"session_name": "team-x",
|
|
10
|
+
"agents": {
|
|
11
|
+
"worker-1": {"status": "running", "agent_id": "worker-1", "window": "worker-1"},
|
|
12
|
+
},
|
|
13
|
+
}),
|
|
14
|
+
)
|
|
15
|
+
.unwrap();
|
|
3
16
|
let tools = TeamOrchestratorTools::with_identity(
|
|
4
|
-
&
|
|
17
|
+
&ws,
|
|
5
18
|
Some(AgentId::new("leader")), // legacy single-team bypasses cross-team refusal
|
|
6
19
|
None,
|
|
7
20
|
);
|
|
@@ -96,8 +96,22 @@
|
|
|
96
96
|
// refusal first (worker-1 not in visible peers) -> PeerNotInScope. owner_team_id=None
|
|
97
97
|
// (legacy single-team) bypasses that, isolating the worker-recipient accepted path.
|
|
98
98
|
// The cross-team refusal has its own tests (refuse_cross_team_peer_* / send_message_cross_team_*).
|
|
99
|
+
// A-7: the accepted path needs a REAL stored message_id (tools.py:175-181 —
|
|
100
|
+
// fabricated ids are gone), so the workspace seeds a running worker-1 the
|
|
101
|
+
// delivery layer can actually queue for.
|
|
102
|
+
let ws = unique_ws("send-worker");
|
|
103
|
+
crate::state::persist::save_runtime_state(
|
|
104
|
+
&ws,
|
|
105
|
+
&serde_json::json!({
|
|
106
|
+
"session_name": "team-x",
|
|
107
|
+
"agents": {
|
|
108
|
+
"worker-1": {"status": "running", "agent_id": "worker-1", "window": "worker-1"},
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
112
|
+
.unwrap();
|
|
99
113
|
let tools = TeamOrchestratorTools::with_identity(
|
|
100
|
-
&
|
|
114
|
+
&ws,
|
|
101
115
|
Some(AgentId::new("leader")),
|
|
102
116
|
None,
|
|
103
117
|
);
|
|
@@ -189,11 +189,12 @@ impl TeamOrchestratorTools {
|
|
|
189
189
|
};
|
|
190
190
|
if is_worker_recipient(to) {
|
|
191
191
|
let out = messaging::send_message(&self.workspace, to, content, &opts).map_err(tool_runtime_error)?;
|
|
192
|
+
// tools.py:175-181 — accepted+poll_via ONLY for a REAL message_id; any other
|
|
193
|
+
// outcome falls back to the compacted direct result. Never invent an
|
|
194
|
+
// `mcp_<timestamp>` id: it does not exist in the store and makes the
|
|
195
|
+
// advertised `team-agent inbox <id>` poll a dead end.
|
|
192
196
|
let message_id = match out.message_id {
|
|
193
197
|
Some(message_id) if out.ok => message_id,
|
|
194
|
-
None if self.owner_team_id.is_none() => {
|
|
195
|
-
format!("mcp_{}", chrono::Utc::now().timestamp_micros())
|
|
196
|
-
}
|
|
197
198
|
_ => {
|
|
198
199
|
let value = delivery_outcome_value(&out);
|
|
199
200
|
let ok = compact_tool_result(&value)?;
|
|
@@ -229,7 +230,13 @@ impl TeamOrchestratorTools {
|
|
|
229
230
|
let team_widens = match (owner_team.as_ref(), requested_team.as_deref()) {
|
|
230
231
|
(_, None) => false,
|
|
231
232
|
(Some(owner), Some(requested)) => {
|
|
232
|
-
|
|
233
|
+
// swallow batch 4 C6: an unreadable state cannot verify the requested
|
|
234
|
+
// team — fail closed (structured transient refusal), never compare
|
|
235
|
+
// against an empty substitute.
|
|
236
|
+
let state = match load_runtime_state(&self.workspace) {
|
|
237
|
+
Ok(state) => state,
|
|
238
|
+
Err(error) => return Err(self.scope_unverifiable(&error.to_string())),
|
|
239
|
+
};
|
|
233
240
|
let requested_canonical = crate::state::projection::resolve_owner_team_id(&state, requested)
|
|
234
241
|
.canonical_key()
|
|
235
242
|
.unwrap_or(requested)
|
|
@@ -463,7 +470,18 @@ impl TeamOrchestratorTools {
|
|
|
463
470
|
"idle_fallback" => Some(messaging::AlertType::IdleFallback),
|
|
464
471
|
"cross_worker_deadlock" => Some(messaging::AlertType::CrossWorkerDeadlock),
|
|
465
472
|
"all" => None,
|
|
466
|
-
|
|
473
|
+
// scheduler.py:268-273 — an unknown alert_type refuses with the Python
|
|
474
|
+
// literal instead of silently widening to suppressing ALL alert families.
|
|
475
|
+
_ => {
|
|
476
|
+
return Ok(ToolOk {
|
|
477
|
+
fields: object_fields(serde_json::json!({
|
|
478
|
+
"ok": false,
|
|
479
|
+
"status": "refused",
|
|
480
|
+
"reason": "invalid_alert_type",
|
|
481
|
+
"alert_type": alert_type,
|
|
482
|
+
})),
|
|
483
|
+
});
|
|
484
|
+
}
|
|
467
485
|
};
|
|
468
486
|
let suppressed_by = self.agent_id.as_ref().map(AgentId::as_str).unwrap_or("leader");
|
|
469
487
|
messaging::stuck_cancel(&self.workspace, agent_id, alert, suppressed_by)
|
|
@@ -571,7 +589,12 @@ impl TeamOrchestratorTools {
|
|
|
571
589
|
// Unresolved/ambiguous owner scope emits mcp.scope_refused; never fallback
|
|
572
590
|
// to active/top-level/sibling teams in a multi-team state.
|
|
573
591
|
let Some(owner_team_id) = &self.owner_team_id else {
|
|
574
|
-
|
|
592
|
+
// swallow batch 4 C5-C8 (MUST-16 fail-closed): the runtime state is the
|
|
593
|
+
// scope truth source — when it cannot be read, the gate REFUSES with a
|
|
594
|
+
// structured transient scope_unverifiable instead of silently treating
|
|
595
|
+
// the workspace as legacy single-team (silent privilege widening).
|
|
596
|
+
let state = load_runtime_state(&self.workspace)
|
|
597
|
+
.map_err(|error| self.scope_unverifiable(&error.to_string()))?;
|
|
575
598
|
if state
|
|
576
599
|
.get("teams")
|
|
577
600
|
.and_then(Value::as_object)
|
|
@@ -582,7 +605,7 @@ impl TeamOrchestratorTools {
|
|
|
582
605
|
return Ok(None);
|
|
583
606
|
};
|
|
584
607
|
let state = load_runtime_state(&self.workspace)
|
|
585
|
-
.map_err(|
|
|
608
|
+
.map_err(|error| self.scope_unverifiable(&error.to_string()))?;
|
|
586
609
|
match canonicalize_owner_team_id(&state, owner_team_id.as_str()) {
|
|
587
610
|
Some(team) => Ok(Some(TeamKey::new(team))),
|
|
588
611
|
None => Err(self.scope_refused("owner team could not be resolved")),
|
|
@@ -591,7 +614,12 @@ impl TeamOrchestratorTools {
|
|
|
591
614
|
|
|
592
615
|
fn canonical_owner_team_key_for_mcp(&self) -> Result<Option<TeamKey>, ToolError> {
|
|
593
616
|
let Some(owner_team_id) = &self.owner_team_id else {
|
|
594
|
-
|
|
617
|
+
// swallow batch 4 C5-C8 (MUST-16 fail-closed): the runtime state is the
|
|
618
|
+
// scope truth source — when it cannot be read, the gate REFUSES with a
|
|
619
|
+
// structured transient scope_unverifiable instead of silently treating
|
|
620
|
+
// the workspace as legacy single-team (silent privilege widening).
|
|
621
|
+
let state = load_runtime_state(&self.workspace)
|
|
622
|
+
.map_err(|error| self.scope_unverifiable(&error.to_string()))?;
|
|
595
623
|
if state
|
|
596
624
|
.get("teams")
|
|
597
625
|
.and_then(Value::as_object)
|
|
@@ -602,13 +630,41 @@ impl TeamOrchestratorTools {
|
|
|
602
630
|
return Ok(None);
|
|
603
631
|
};
|
|
604
632
|
let state = load_runtime_state(&self.workspace)
|
|
605
|
-
.map_err(|
|
|
633
|
+
.map_err(|error| self.scope_unverifiable(&error.to_string()))?;
|
|
606
634
|
match canonicalize_owner_team_id(&state, owner_team_id.as_str()) {
|
|
607
635
|
Some(team) => Ok(Some(TeamKey::new(team))),
|
|
608
636
|
None => Err(self.scope_refused("owner team could not be resolved")),
|
|
609
637
|
}
|
|
610
638
|
}
|
|
611
639
|
|
|
640
|
+
/// swallow batch 4 C5/C6/N38: the structured FAIL-CLOSED refusal for "the scope
|
|
641
|
+
/// could not be verified" (state read failed). Transient by design — the same call
|
|
642
|
+
/// passes once the state is readable again; the caller's self-reported scope is
|
|
643
|
+
/// never trusted as a fallback (MUST-16 ceiling).
|
|
644
|
+
fn scope_unverifiable(&self, io_error: &str) -> ToolError {
|
|
645
|
+
let _ = EventLog::new(&self.workspace).write(
|
|
646
|
+
"mcp.scope_state_read_failed",
|
|
647
|
+
serde_json::json!({
|
|
648
|
+
"reason": "scope_unverifiable",
|
|
649
|
+
"requested_owner_team_id": self.owner_team_id.as_ref().map(TeamKey::as_str),
|
|
650
|
+
"error": io_error,
|
|
651
|
+
}),
|
|
652
|
+
);
|
|
653
|
+
let mut extra = serde_json::Map::new();
|
|
654
|
+
extra.insert("status".to_string(), Value::String("refused".to_string()));
|
|
655
|
+
extra.insert("kind".to_string(), Value::String("scope_unverifiable".to_string()));
|
|
656
|
+
extra.insert(
|
|
657
|
+
"next_action".to_string(),
|
|
658
|
+
Value::String("retry shortly or check the runtime state path".to_string()),
|
|
659
|
+
);
|
|
660
|
+
ToolError {
|
|
661
|
+
reason: ToolErrorReason::McpScopeRefused,
|
|
662
|
+
exc_type: "McpScopeUnverifiable".to_string(),
|
|
663
|
+
message: format!("scope_unverifiable: state read failed: {io_error}"),
|
|
664
|
+
extra,
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
612
668
|
fn scope_refused(&self, message: &str) -> ToolError {
|
|
613
669
|
let canonical_owner_team_id = self.canonical_owner_team_key_for_event();
|
|
614
670
|
let _ = EventLog::new(&self.workspace).write(
|
|
@@ -444,7 +444,8 @@ pub(crate) fn dispatch_tool(tools: &TeamOrchestratorTools, tool: McpTool, args:
|
|
|
444
444
|
McpTool::StuckList => tools.stuck_list(),
|
|
445
445
|
McpTool::StuckCancel => tools.stuck_cancel(
|
|
446
446
|
args.get("agent_id").and_then(Value::as_str).unwrap_or(""),
|
|
447
|
-
|
|
447
|
+
// tools.py:351 — the MCP default alert_type is "stuck", not "all".
|
|
448
|
+
args.get("alert_type").and_then(Value::as_str).unwrap_or("stuck"),
|
|
448
449
|
),
|
|
449
450
|
}
|
|
450
451
|
}
|
|
@@ -320,6 +320,54 @@ impl MessageStore {
|
|
|
320
320
|
Ok(rows.into_iter().rev().collect())
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
+
/// `latest_results` (`core.py:458-471`): newest non-invalid result rows, oldest
|
|
324
|
+
/// first (Python fetches `created_at desc limit ?` then reverses).
|
|
325
|
+
pub fn latest_results(
|
|
326
|
+
&self,
|
|
327
|
+
limit: usize,
|
|
328
|
+
owner_team_id: Option<&str>,
|
|
329
|
+
) -> Result<Vec<serde_json::Value>, MessageStoreError> {
|
|
330
|
+
let conn = crate::db::schema::open_db(&self.path)?;
|
|
331
|
+
let limit = i64::try_from(limit).unwrap_or(i64::MAX);
|
|
332
|
+
let sql = match owner_team_id {
|
|
333
|
+
Some(_) => {
|
|
334
|
+
"select owner_team_id, result_id, task_id, agent_id, envelope, status, created_at
|
|
335
|
+
from results
|
|
336
|
+
where status != 'invalid' and owner_team_id = ?2
|
|
337
|
+
order by created_at desc
|
|
338
|
+
limit ?1"
|
|
339
|
+
}
|
|
340
|
+
None => {
|
|
341
|
+
"select owner_team_id, result_id, task_id, agent_id, envelope, status, created_at
|
|
342
|
+
from results
|
|
343
|
+
where status != 'invalid'
|
|
344
|
+
order by created_at desc
|
|
345
|
+
limit ?1"
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
let mut stmt = conn.prepare(sql)?;
|
|
349
|
+
let map_row = |row: &rusqlite::Row<'_>| {
|
|
350
|
+
Ok(serde_json::json!({
|
|
351
|
+
"owner_team_id": row.get::<_, Option<String>>(0)?,
|
|
352
|
+
"result_id": row.get::<_, String>(1)?,
|
|
353
|
+
"task_id": row.get::<_, Option<String>>(2)?,
|
|
354
|
+
"agent_id": row.get::<_, Option<String>>(3)?,
|
|
355
|
+
"envelope": row.get::<_, Option<String>>(4)?,
|
|
356
|
+
"status": row.get::<_, Option<String>>(5)?,
|
|
357
|
+
"created_at": row.get::<_, Option<String>>(6)?,
|
|
358
|
+
}))
|
|
359
|
+
};
|
|
360
|
+
let rows = match owner_team_id {
|
|
361
|
+
Some(team) => stmt
|
|
362
|
+
.query_map(params![limit, team], map_row)?
|
|
363
|
+
.collect::<Result<Vec<_>, _>>()?,
|
|
364
|
+
None => stmt
|
|
365
|
+
.query_map(params![limit], map_row)?
|
|
366
|
+
.collect::<Result<Vec<_>, _>>()?,
|
|
367
|
+
};
|
|
368
|
+
Ok(rows.into_iter().rev().collect())
|
|
369
|
+
}
|
|
370
|
+
|
|
323
371
|
/// Allow direct peer messages in both directions. Golden stores `(a,b)` and
|
|
324
372
|
/// `(b,a)` so either sender/recipient lookup can use a single ordered key.
|
|
325
373
|
pub fn allow_peer(&self, a: &str, b: &str) -> Result<(), MessageStoreError> {
|
|
@@ -410,6 +458,38 @@ fn row_to_message_value(row: &rusqlite::Row<'_>) -> rusqlite::Result<serde_json:
|
|
|
410
458
|
}))
|
|
411
459
|
}
|
|
412
460
|
|
|
461
|
+
/// `result_summary_from_row`(`status/queries.py:92-106`):解析 result 行的 envelope,
|
|
462
|
+
/// 产出 status/watch 共用的 result 摘要;envelope 坏/非对象 → `None`。
|
|
463
|
+
pub fn result_summary_from_row(row: &serde_json::Value) -> Option<serde_json::Value> {
|
|
464
|
+
let envelope = match row.get("envelope") {
|
|
465
|
+
Some(serde_json::Value::String(text)) => {
|
|
466
|
+
serde_json::from_str::<serde_json::Value>(text).ok()?
|
|
467
|
+
}
|
|
468
|
+
Some(value @ serde_json::Value::Object(_)) => value.clone(),
|
|
469
|
+
_ => return None,
|
|
470
|
+
};
|
|
471
|
+
if !envelope.is_object() {
|
|
472
|
+
return None;
|
|
473
|
+
}
|
|
474
|
+
// Python `envelope.get(k) or row.get(k)` — falsy (null/empty) falls through to the row.
|
|
475
|
+
let pick = |key: &str| {
|
|
476
|
+
envelope
|
|
477
|
+
.get(key)
|
|
478
|
+
.filter(|v| !v.is_null() && v.as_str() != Some(""))
|
|
479
|
+
.or_else(|| row.get(key))
|
|
480
|
+
.cloned()
|
|
481
|
+
.unwrap_or(serde_json::Value::Null)
|
|
482
|
+
};
|
|
483
|
+
Some(serde_json::json!({
|
|
484
|
+
"result_id": row.get("result_id").cloned().unwrap_or(serde_json::Value::Null),
|
|
485
|
+
"task_id": pick("task_id"),
|
|
486
|
+
"agent_id": pick("agent_id"),
|
|
487
|
+
"status": pick("status"),
|
|
488
|
+
"summary": envelope.get("summary").cloned().unwrap_or(serde_json::Value::Null),
|
|
489
|
+
"created_at": row.get("created_at").cloned().unwrap_or(serde_json::Value::Null),
|
|
490
|
+
}))
|
|
491
|
+
}
|
|
492
|
+
|
|
413
493
|
fn now_ts() -> String {
|
|
414
494
|
chrono::Utc::now().to_rfc3339()
|
|
415
495
|
}
|