@team-agent/installer 0.5.113 → 0.5.114
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 +252 -84
- package/Cargo.toml +3 -1
- package/crates/team-agent/Cargo.toml +3 -0
- package/crates/team-agent/src/cli/adapters.rs +28 -15
- package/crates/team-agent/src/cli/diagnose.rs +36 -13
- package/crates/team-agent/src/cli/emit.rs +55 -20
- package/crates/team-agent/src/cli/leader.rs +16 -0
- package/crates/team-agent/src/cli/leader_prompt.rs +261 -0
- package/crates/team-agent/src/cli/mod.rs +152 -8
- package/crates/team-agent/src/cli/models.rs +68 -18
- package/crates/team-agent/src/cli/spec.rs +7 -1
- package/crates/team-agent/src/cli/status_port/brief.rs +268 -101
- package/crates/team-agent/src/cli/tests/base.rs +88 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +2 -0
- package/crates/team-agent/src/compiler.rs +66 -18
- package/crates/team-agent/src/contract_runtime/backend.rs +646 -0
- package/crates/team-agent/src/contract_runtime/config.rs +309 -0
- package/crates/team-agent/src/contract_runtime/discovery.rs +237 -0
- package/crates/team-agent/src/contract_runtime/forward.rs +229 -0
- package/crates/team-agent/src/contract_runtime/framework.rs +261 -0
- package/crates/team-agent/src/contract_runtime/mcp.rs +396 -0
- package/crates/team-agent/src/contract_runtime/mod.rs +14 -0
- package/crates/team-agent/src/contract_runtime/prompt.rs +216 -0
- package/crates/team-agent/src/contract_runtime/pump.rs +207 -0
- package/crates/team-agent/src/contract_runtime/registry.rs +30 -0
- package/crates/team-agent/src/coordinator/tick.rs +26 -8
- package/crates/team-agent/src/leader/mod.rs +1 -0
- package/crates/team-agent/src/leader/prompt.rs +906 -0
- package/crates/team-agent/src/leader/start.rs +260 -16
- package/crates/team-agent/src/leader/tests/mod.rs +2 -0
- package/crates/team-agent/src/leader/tests/prompt_contract.rs +268 -0
- package/crates/team-agent/src/leader/types.rs +6 -0
- package/crates/team-agent/src/lib.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +472 -388
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +38 -9
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +17 -0
- package/crates/team-agent/src/lifecycle/launch.rs +39 -28
- package/crates/team-agent/src/lifecycle/types.rs +4 -1
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +37 -27
- package/crates/team-agent/src/main.rs +6 -0
- package/crates/team-agent/src/messaging/delivery.rs +50 -1
- package/crates/team-agent/src/messaging/send.rs +10 -0
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +131 -0
- package/crates/team-agent/src/model/spec.rs +24 -12
- package/crates/team-agent/src/state/persist.rs +73 -0
- package/package.json +4 -4
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
//! Owned native stdio entry. Invocation arguments identify an expected record;
|
|
2
|
+
//! only actual executable/birth/parent and current instance checks authorize it.
|
|
3
|
+
use std::fmt::Display;
|
|
4
|
+
use std::io::Write;
|
|
5
|
+
use std::path::Path;
|
|
6
|
+
use std::time::{Duration, Instant};
|
|
7
|
+
|
|
8
|
+
use serde_json::{json, Value};
|
|
9
|
+
use team_agent_contract::contract::types::*;
|
|
10
|
+
use team_agent_contract::host::process::{
|
|
11
|
+
capture_process, verify_native_ancestry, MAX_NATIVE_PARENT_HOPS,
|
|
12
|
+
};
|
|
13
|
+
use team_agent_contract::orchestration::{
|
|
14
|
+
mcp::*,
|
|
15
|
+
protocol::ConnectionRecord,
|
|
16
|
+
store::{ContractStore, SeatRecord, SeatStatus},
|
|
17
|
+
Error,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
use super::backend::{Backend, BackendError};
|
|
21
|
+
|
|
22
|
+
// Diagnostics are not protocol facts or admission. Never write them to stdout,
|
|
23
|
+
// include request/argv/env bytes, or let a failed log write change the result.
|
|
24
|
+
struct Diagnostics {
|
|
25
|
+
log: Option<crate::event_log::EventLog>,
|
|
26
|
+
fields: Value,
|
|
27
|
+
}
|
|
28
|
+
impl Diagnostics {
|
|
29
|
+
fn new() -> Self {
|
|
30
|
+
Self {
|
|
31
|
+
log: None,
|
|
32
|
+
fields: json!({
|
|
33
|
+
"pid": std::process::id(),
|
|
34
|
+
"entry_ppid": crate::platform::process::current_parent_pid(),
|
|
35
|
+
}),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
fn bind_log(&mut self, workspace: &Path) {
|
|
40
|
+
let directory = crate::model::paths::logs_dir(workspace);
|
|
41
|
+
let path = directory.join("events.jsonl");
|
|
42
|
+
// Entry diagnostics must not create an unverified workspace, follow a
|
|
43
|
+
// redirected log path, or depend on successfully opening contract.db.
|
|
44
|
+
let regular_or_absent = std::fs::symlink_metadata(&path).map_or_else(
|
|
45
|
+
|error| error.kind() == std::io::ErrorKind::NotFound,
|
|
46
|
+
|metadata| metadata.is_file() && !metadata.file_type().is_symlink(),
|
|
47
|
+
);
|
|
48
|
+
if workspace.is_absolute()
|
|
49
|
+
&& workspace.canonicalize().is_ok_and(|path| path == workspace)
|
|
50
|
+
&& directory.canonicalize().is_ok_and(|path| path == directory)
|
|
51
|
+
&& regular_or_absent
|
|
52
|
+
{
|
|
53
|
+
self.log = Some(crate::event_log::EventLog::new(workspace));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
fn record(&self, stage: &str, outcome: &str, error: Option<&dyn Display>) -> Value {
|
|
58
|
+
let mut value = self.fields.clone();
|
|
59
|
+
value["event"] = json!("contract.mcp");
|
|
60
|
+
value["ts"] = json!(chrono::Utc::now().to_rfc3339());
|
|
61
|
+
value["stage"] = json!(stage);
|
|
62
|
+
value["outcome"] = json!(outcome);
|
|
63
|
+
if let Some(error) = error {
|
|
64
|
+
let text = crate::redaction::redact_external_text(&error.to_string());
|
|
65
|
+
value["error"] = json!(text.chars().take(256).collect::<String>());
|
|
66
|
+
value["error_truncated"] = json!(text.chars().count() > 256);
|
|
67
|
+
}
|
|
68
|
+
crate::redaction::redact_external_value(&value)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fn emit(&self, stage: &str, outcome: &str, error: Option<&dyn Display>) {
|
|
72
|
+
let value = self.record(stage, outcome, error);
|
|
73
|
+
write_diagnostic(self.log.as_ref(), &value, &mut std::io::stderr().lock());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
fn step<T, E: Display>(
|
|
77
|
+
&self,
|
|
78
|
+
stage: &str,
|
|
79
|
+
action: impl FnOnce() -> Result<T, E>,
|
|
80
|
+
) -> Result<T, E> {
|
|
81
|
+
self.emit(stage, "started", None);
|
|
82
|
+
let result = action();
|
|
83
|
+
self.emit(
|
|
84
|
+
stage,
|
|
85
|
+
if result.is_ok() { "ok" } else { "error" },
|
|
86
|
+
result.as_ref().err().map(|error| error as &dyn Display),
|
|
87
|
+
);
|
|
88
|
+
result
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
fn write_diagnostic(
|
|
93
|
+
log: Option<&crate::event_log::EventLog>,
|
|
94
|
+
value: &Value,
|
|
95
|
+
stderr: &mut impl Write,
|
|
96
|
+
) {
|
|
97
|
+
if log.is_some_and(|log| log.write("contract.mcp", value.clone()).is_ok()) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if let Ok(mut bytes) = serde_json::to_vec(value) {
|
|
101
|
+
// Valid bounded JSON even if future diagnostic fields grow unexpectedly.
|
|
102
|
+
if bytes.len() > 8191 {
|
|
103
|
+
bytes = br#"{"event":"contract.mcp","stage":"diagnostic_size_limit"}"#.to_vec();
|
|
104
|
+
}
|
|
105
|
+
bytes.push(b'\n');
|
|
106
|
+
let _ = stderr.write_all(&bytes);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
fn arguments(
|
|
111
|
+
args: &[String],
|
|
112
|
+
) -> Result<(std::path::PathBuf, String, SeatId, InstanceId, Generation), BackendError> {
|
|
113
|
+
let mut values = std::collections::BTreeMap::new();
|
|
114
|
+
if !args.len().is_multiple_of(2) {
|
|
115
|
+
return Err(BackendError::Metadata);
|
|
116
|
+
}
|
|
117
|
+
for pair in args.chunks_exact(2) {
|
|
118
|
+
if !matches!(
|
|
119
|
+
pair[0].as_str(),
|
|
120
|
+
"--workspace" | "--team" | "--seat" | "--instance" | "--generation"
|
|
121
|
+
) || values.insert(pair[0].as_str(), pair[1].as_str()).is_some()
|
|
122
|
+
{
|
|
123
|
+
return Err(BackendError::Metadata);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if values.len() != 5 {
|
|
127
|
+
return Err(BackendError::Metadata);
|
|
128
|
+
}
|
|
129
|
+
let get = |key| values.get(key).copied().ok_or(BackendError::Metadata);
|
|
130
|
+
Ok((
|
|
131
|
+
get("--workspace")?.into(),
|
|
132
|
+
get("--team")?.into(),
|
|
133
|
+
SeatId::new(get("--seat")?)?,
|
|
134
|
+
InstanceId::new(get("--instance")?)?,
|
|
135
|
+
Generation(
|
|
136
|
+
get("--generation")?
|
|
137
|
+
.parse()
|
|
138
|
+
.map_err(|_| BackendError::Metadata)?,
|
|
139
|
+
),
|
|
140
|
+
))
|
|
141
|
+
}
|
|
142
|
+
fn wait_target(store: &ContractStore, identity: &InstanceIdentity) -> Result<SeatRecord, Error> {
|
|
143
|
+
let deadline = Instant::now() + Duration::from_secs(30);
|
|
144
|
+
loop {
|
|
145
|
+
let seat = store.assert_current(identity)?;
|
|
146
|
+
if matches!(seat.status, SeatStatus::Stopped | SeatStatus::Unknown) {
|
|
147
|
+
return Err(Error::Fence);
|
|
148
|
+
}
|
|
149
|
+
if seat.physical.is_some() {
|
|
150
|
+
return Ok(seat);
|
|
151
|
+
}
|
|
152
|
+
if Instant::now() >= deadline {
|
|
153
|
+
return Err(Error::Host("native target publication timed out"));
|
|
154
|
+
}
|
|
155
|
+
std::thread::sleep(Duration::from_millis(25));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
pub fn run(args: &[String]) -> Result<(), BackendError> {
|
|
160
|
+
let mut diagnostics = Diagnostics::new();
|
|
161
|
+
// This is the first action in the main.rs contract-mcp dispatch target.
|
|
162
|
+
// Arguments are not trusted as a log destination yet: stderr only.
|
|
163
|
+
diagnostics.emit("entry", "entered", None);
|
|
164
|
+
let result = run_observed(args, &mut diagnostics);
|
|
165
|
+
diagnostics.emit(
|
|
166
|
+
"exit",
|
|
167
|
+
if result.is_ok() { "ok" } else { "error" },
|
|
168
|
+
result.as_ref().err().map(|error| error as &dyn Display),
|
|
169
|
+
);
|
|
170
|
+
result
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
fn run_observed(args: &[String], diagnostics: &mut Diagnostics) -> Result<(), BackendError> {
|
|
174
|
+
let (workspace, team, seat, instance, generation) =
|
|
175
|
+
diagnostics.step("arguments", || arguments(args))?;
|
|
176
|
+
diagnostics.bind_log(&workspace);
|
|
177
|
+
diagnostics.fields["seat"] = json!(seat.as_str().chars().take(96).collect::<String>());
|
|
178
|
+
diagnostics.fields["instance"] = json!(instance.as_str().chars().take(96).collect::<String>());
|
|
179
|
+
diagnostics.fields["generation"] = json!(generation.0);
|
|
180
|
+
diagnostics.emit("entry", "arguments_validated", None);
|
|
181
|
+
let mut backend = diagnostics.step("backend_open", || Backend::open(&workspace, &team))?;
|
|
182
|
+
let identity = InstanceIdentity {
|
|
183
|
+
scope: backend.binding.scope.clone(),
|
|
184
|
+
seat,
|
|
185
|
+
instance,
|
|
186
|
+
generation,
|
|
187
|
+
};
|
|
188
|
+
diagnostics.fields["scope"] = json!(identity.scope.as_str());
|
|
189
|
+
let seat = diagnostics.step("target_wait", || wait_target(&backend.store, &identity))?;
|
|
190
|
+
let target = seat
|
|
191
|
+
.physical
|
|
192
|
+
.as_ref()
|
|
193
|
+
.ok_or(Error::Invalid("native target missing"))?;
|
|
194
|
+
diagnostics.fields["expected_native_pid"] = json!(target.process.identity.pid);
|
|
195
|
+
diagnostics.emit("target_published", "observed", None);
|
|
196
|
+
let process = diagnostics.step("process_capture", || {
|
|
197
|
+
capture_process(
|
|
198
|
+
std::process::id(),
|
|
199
|
+
&backend.binding.candidate,
|
|
200
|
+
backend.binding.candidate_sha256,
|
|
201
|
+
Duration::from_secs(10),
|
|
202
|
+
)
|
|
203
|
+
})?;
|
|
204
|
+
diagnostics.fields["captured_ppid"] = json!(process.parent);
|
|
205
|
+
diagnostics.fields["direct_parent_matches"] =
|
|
206
|
+
json!(process.parent == target.process.identity.pid);
|
|
207
|
+
diagnostics.fields["max_parent_hops"] = json!(MAX_NATIVE_PARENT_HOPS);
|
|
208
|
+
diagnostics.emit("parent_fence", "started", None);
|
|
209
|
+
let ancestry = verify_native_ancestry(&process, &target.process);
|
|
210
|
+
diagnostics.fields["parent_matches"] = json!(ancestry.is_ok());
|
|
211
|
+
if let Ok(chain) = &ancestry {
|
|
212
|
+
diagnostics.fields["ancestor_chain"] = json!(chain);
|
|
213
|
+
}
|
|
214
|
+
diagnostics.emit(
|
|
215
|
+
"parent_fence",
|
|
216
|
+
if ancestry.is_ok() { "ok" } else { "error" },
|
|
217
|
+
ancestry.as_ref().err().map(|error| error as &dyn Display),
|
|
218
|
+
);
|
|
219
|
+
ancestry?;
|
|
220
|
+
let connection = diagnostics
|
|
221
|
+
.step("connection_identity", || {
|
|
222
|
+
backend.store.propose_identity(&identity.seat)
|
|
223
|
+
})?
|
|
224
|
+
.instance;
|
|
225
|
+
let record = ConnectionRecord {
|
|
226
|
+
identity: identity.clone(),
|
|
227
|
+
connection: connection.clone(),
|
|
228
|
+
server_key: seat.server_key.clone(),
|
|
229
|
+
binding_key: seat.binding_key.clone(),
|
|
230
|
+
process,
|
|
231
|
+
native_process: target.process.clone(),
|
|
232
|
+
closed: false,
|
|
233
|
+
};
|
|
234
|
+
diagnostics.step("register_connection", || {
|
|
235
|
+
backend.store.register_connection(&record)
|
|
236
|
+
})?;
|
|
237
|
+
// Registration is not an initialize/tools-list/client-consumption fact.
|
|
238
|
+
// Those continue to be recorded only at the existing protocol boundaries.
|
|
239
|
+
let result = diagnostics.step("serve", || {
|
|
240
|
+
serve_with_diagnostics(
|
|
241
|
+
&mut backend.store,
|
|
242
|
+
|store, request| {
|
|
243
|
+
if verify_native_ancestry(&record.process, &record.native_process).is_err() {
|
|
244
|
+
return Err(Error::Fence);
|
|
245
|
+
}
|
|
246
|
+
let current = store.assert_current(&identity)?;
|
|
247
|
+
if current.binding_key != record.binding_key
|
|
248
|
+
|| matches!(current.status, SeatStatus::Stopped | SeatStatus::Unknown)
|
|
249
|
+
|| current
|
|
250
|
+
.physical
|
|
251
|
+
.as_ref()
|
|
252
|
+
.is_none_or(|target| target.process != record.native_process)
|
|
253
|
+
{
|
|
254
|
+
return Err(Error::Fence);
|
|
255
|
+
}
|
|
256
|
+
let business =
|
|
257
|
+
request.get("method").and_then(serde_json::Value::as_str) == Some("tools/call");
|
|
258
|
+
let mut task = String::new();
|
|
259
|
+
if business {
|
|
260
|
+
// A native callback can race the physical receipt's DB commit. Wait
|
|
261
|
+
// for that exact generation's submitted turn, never a queued row.
|
|
262
|
+
let deadline = Instant::now() + Duration::from_secs(5);
|
|
263
|
+
loop {
|
|
264
|
+
match store.submitted_task(&identity) {
|
|
265
|
+
Ok(Some(bound)) => {
|
|
266
|
+
task = bound;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
Ok(None) | Err(Error::Conflict) if Instant::now() < deadline => {
|
|
270
|
+
std::thread::sleep(Duration::from_millis(20))
|
|
271
|
+
}
|
|
272
|
+
Ok(None) => return Err(Error::Invalid("no submitted native turn")),
|
|
273
|
+
Err(error) => return Err(error),
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if verify_native_ancestry(&record.process, &record.native_process).is_err() {
|
|
278
|
+
return Err(Error::Fence);
|
|
279
|
+
}
|
|
280
|
+
Ok(CallContext {
|
|
281
|
+
identity: identity.clone(),
|
|
282
|
+
binding_key: record.binding_key.clone(),
|
|
283
|
+
connection_id: connection.clone(),
|
|
284
|
+
task_id: task,
|
|
285
|
+
})
|
|
286
|
+
},
|
|
287
|
+
std::io::stdin().lock(),
|
|
288
|
+
std::io::stdout().lock(),
|
|
289
|
+
|stage, error| {
|
|
290
|
+
diagnostics.emit(
|
|
291
|
+
match stage {
|
|
292
|
+
RequestRejectionStage::Context => "request_context",
|
|
293
|
+
RequestRejectionStage::Handler => "request_handler",
|
|
294
|
+
},
|
|
295
|
+
"rejected",
|
|
296
|
+
Some(error),
|
|
297
|
+
);
|
|
298
|
+
},
|
|
299
|
+
)
|
|
300
|
+
});
|
|
301
|
+
let closed = diagnostics.step("close_connection", || {
|
|
302
|
+
backend.store.close_connection(&identity, &connection)
|
|
303
|
+
});
|
|
304
|
+
result?;
|
|
305
|
+
closed?;
|
|
306
|
+
Ok(())
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
#[cfg(test)]
|
|
310
|
+
mod tests {
|
|
311
|
+
use super::*;
|
|
312
|
+
|
|
313
|
+
#[test]
|
|
314
|
+
fn diagnostics_preserve_errors_and_distinguish_captured_and_expected_parent() {
|
|
315
|
+
let mut diagnostics = Diagnostics::new();
|
|
316
|
+
diagnostics.fields["captured_ppid"] = json!(202);
|
|
317
|
+
diagnostics.fields["expected_native_pid"] = json!(303);
|
|
318
|
+
diagnostics.fields["parent_matches"] = json!(false);
|
|
319
|
+
let result = diagnostics.step("parent_fence", || Err::<(), _>(Error::Fence));
|
|
320
|
+
assert_eq!(result, Err(Error::Fence));
|
|
321
|
+
let event = diagnostics.record("parent_fence", "error", Some(&Error::Fence));
|
|
322
|
+
assert_eq!(event["captured_ppid"], 202);
|
|
323
|
+
assert_eq!(event["expected_native_pid"], 303);
|
|
324
|
+
assert_eq!(event["parent_matches"], false);
|
|
325
|
+
assert_eq!(event["error"], Error::Fence.to_string());
|
|
326
|
+
assert!(event["entry_ppid"].is_number());
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
#[test]
|
|
330
|
+
fn rejected_requests_keep_the_actual_error_category_and_stage() {
|
|
331
|
+
let diagnostics = Diagnostics::new();
|
|
332
|
+
for (stage, error) in [
|
|
333
|
+
("request_context", Error::Conflict),
|
|
334
|
+
("request_context", Error::Fence),
|
|
335
|
+
("request_context", Error::Database),
|
|
336
|
+
("request_handler", Error::Invalid("argument type")),
|
|
337
|
+
] {
|
|
338
|
+
let event = diagnostics.record(stage, "rejected", Some(&error));
|
|
339
|
+
assert_eq!(event["stage"], stage);
|
|
340
|
+
assert_eq!(event["outcome"], "rejected");
|
|
341
|
+
assert_eq!(event["error"], error.to_string());
|
|
342
|
+
assert!(event.get("request").is_none());
|
|
343
|
+
assert!(event.get("arguments").is_none());
|
|
344
|
+
let mut stderr = Vec::new();
|
|
345
|
+
write_diagnostic(None, &event, &mut stderr);
|
|
346
|
+
assert!(stderr.len() <= 8192);
|
|
347
|
+
assert_eq!(serde_json::from_slice::<Value>(&stderr).unwrap(), event);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
#[test]
|
|
352
|
+
fn diagnostics_redact_and_bound_error_without_argument_or_environment_fields() {
|
|
353
|
+
let diagnostics = Diagnostics::new();
|
|
354
|
+
let error = format!("PASSWORD=do-not-log {}", "x".repeat(20_000));
|
|
355
|
+
let event = diagnostics.record("arguments", "error", Some(&error));
|
|
356
|
+
assert_eq!(event["error_truncated"], true);
|
|
357
|
+
assert!(!event.to_string().contains("do-not-log"));
|
|
358
|
+
for field in ["argv", "args", "env", "prompt", "request", "content"] {
|
|
359
|
+
assert!(event.get(field).is_none());
|
|
360
|
+
}
|
|
361
|
+
let mut stderr = Vec::new();
|
|
362
|
+
write_diagnostic(None, &event, &mut stderr);
|
|
363
|
+
assert!(stderr.len() <= 8192);
|
|
364
|
+
assert_eq!(serde_json::from_slice::<Value>(&stderr).unwrap(), event);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#[test]
|
|
368
|
+
fn diagnostic_sink_failures_do_not_change_execution_results() {
|
|
369
|
+
let log = crate::event_log::EventLog::at("/dev/null/contract-mcp-events".into());
|
|
370
|
+
let diagnostics = Diagnostics::new();
|
|
371
|
+
let event = diagnostics.record("backend_open", "error", Some(&Error::Fence));
|
|
372
|
+
let mut stderr = Vec::new();
|
|
373
|
+
write_diagnostic(Some(&log), &event, &mut stderr);
|
|
374
|
+
assert_eq!(serde_json::from_slice::<Value>(&stderr).unwrap(), event);
|
|
375
|
+
struct Broken;
|
|
376
|
+
impl Write for Broken {
|
|
377
|
+
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
|
|
378
|
+
Err(std::io::ErrorKind::BrokenPipe.into())
|
|
379
|
+
}
|
|
380
|
+
fn flush(&mut self) -> std::io::Result<()> {
|
|
381
|
+
Err(std::io::ErrorKind::BrokenPipe.into())
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
write_diagnostic(Some(&log), &event, &mut Broken);
|
|
385
|
+
assert_eq!(diagnostics.step("fixture", || Ok::<_, Error>(17)), Ok(17));
|
|
386
|
+
stderr.clear();
|
|
387
|
+
write_diagnostic(
|
|
388
|
+
None,
|
|
389
|
+
&json!({"unexpected": "x".repeat(20_000)}),
|
|
390
|
+
&mut stderr,
|
|
391
|
+
);
|
|
392
|
+
let bounded: Value = serde_json::from_slice(&stderr).unwrap();
|
|
393
|
+
assert_eq!(bounded["stage"], "diagnostic_size_limit");
|
|
394
|
+
assert!(stderr.len() <= 8192);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//! Per-agent boundary into the contract runtime. Existing provider internals
|
|
2
|
+
//! stay on their path, including mixed teams; a seat never has two executors.
|
|
3
|
+
//!
|
|
4
|
+
//! Selection/preflight is read-only. Physical operations require a validated
|
|
5
|
+
//! contract role and its real framework/native binding, not a provider guess.
|
|
6
|
+
pub mod backend;
|
|
7
|
+
pub mod config;
|
|
8
|
+
pub mod discovery;
|
|
9
|
+
pub mod forward;
|
|
10
|
+
pub mod framework;
|
|
11
|
+
pub mod mcp;
|
|
12
|
+
pub mod prompt;
|
|
13
|
+
pub mod pump;
|
|
14
|
+
pub mod registry;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
//! Provider-independent instruction source policy. The Kiro adapter receives
|
|
2
|
+
//! one resolved prompt, never a provider-private home/path search algorithm.
|
|
3
|
+
use std::io::Read;
|
|
4
|
+
use std::path::{Path, PathBuf};
|
|
5
|
+
|
|
6
|
+
use serde::{Deserialize, Serialize};
|
|
7
|
+
use team_agent_contract::contract::types::Digest;
|
|
8
|
+
use team_agent_contract::host::digest;
|
|
9
|
+
|
|
10
|
+
use super::config::ConfigError;
|
|
11
|
+
use crate::communication_mode::CommunicationMode;
|
|
12
|
+
use crate::lifecycle::worker_command_context::{runtime_contract_section, worker_identity_section};
|
|
13
|
+
use crate::model::yaml::Value;
|
|
14
|
+
|
|
15
|
+
pub const MAX_PROMPT_BYTES: usize = 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
/// Source contents are private; public diagnostics project only their receipts.
|
|
18
|
+
#[derive(Clone)]
|
|
19
|
+
pub struct Source {
|
|
20
|
+
pub path: PathBuf,
|
|
21
|
+
pub contents: String,
|
|
22
|
+
}
|
|
23
|
+
impl Source {
|
|
24
|
+
pub fn read(path: &Path) -> Result<Self, ConfigError> {
|
|
25
|
+
let resolved = path.canonicalize()?;
|
|
26
|
+
let mut bytes = Vec::new();
|
|
27
|
+
std::fs::File::open(&resolved)?
|
|
28
|
+
.take(MAX_PROMPT_BYTES as u64 + 1)
|
|
29
|
+
.read_to_end(&mut bytes)?;
|
|
30
|
+
if bytes.len() > MAX_PROMPT_BYTES {
|
|
31
|
+
return Err(ConfigError::Unsupported(
|
|
32
|
+
"instruction source exceeds the bounded prompt limit; no truncation is performed",
|
|
33
|
+
));
|
|
34
|
+
}
|
|
35
|
+
let contents = String::from_utf8(bytes).map_err(|_| ConfigError::Invalid {
|
|
36
|
+
path: path.into(),
|
|
37
|
+
field: "UTF-8 instructions",
|
|
38
|
+
})?;
|
|
39
|
+
if contents.contains('\0') {
|
|
40
|
+
return Err(ConfigError::Invalid {
|
|
41
|
+
path: path.into(),
|
|
42
|
+
field: "NUL in instructions",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
Ok(Self {
|
|
46
|
+
path: resolved,
|
|
47
|
+
contents,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
fn optional(path: &Path) -> Result<Option<Self>, ConfigError> {
|
|
51
|
+
match std::fs::symlink_metadata(path) {
|
|
52
|
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
|
53
|
+
Err(error) => Err(error.into()),
|
|
54
|
+
Ok(_) => Self::read(path).map(Some),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
60
|
+
pub enum Layer {
|
|
61
|
+
UserGlobal,
|
|
62
|
+
Project,
|
|
63
|
+
Role,
|
|
64
|
+
TeamExplicit,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
68
|
+
pub struct InstructionReceipt {
|
|
69
|
+
pub layer: Layer,
|
|
70
|
+
pub path: PathBuf,
|
|
71
|
+
pub sha256: Digest,
|
|
72
|
+
pub bytes: usize,
|
|
73
|
+
/// Exact UTF-8 byte range in the assembled native prompt, excluding headings.
|
|
74
|
+
pub start: usize,
|
|
75
|
+
pub end: usize,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#[derive(Clone, Serialize, Deserialize)]
|
|
79
|
+
pub struct Prompt {
|
|
80
|
+
pub text: String,
|
|
81
|
+
pub sha256: Digest,
|
|
82
|
+
pub sources: Vec<InstructionReceipt>,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Central compatibility default, not a Kiro adapter constant. An explicit
|
|
86
|
+
/// unified TEAM_AGENT_USER_INSTRUCTIONS file takes precedence and is required
|
|
87
|
+
/// to exist. Without an override the authorized user-level AGENTS is optional.
|
|
88
|
+
pub fn user_source() -> Result<Option<Source>, ConfigError> {
|
|
89
|
+
if let Some(path) = std::env::var_os("TEAM_AGENT_USER_INSTRUCTIONS") {
|
|
90
|
+
if path.is_empty() {
|
|
91
|
+
return Err(ConfigError::Unsupported(
|
|
92
|
+
"TEAM_AGENT_USER_INSTRUCTIONS must name a file",
|
|
93
|
+
));
|
|
94
|
+
}
|
|
95
|
+
return Source::read(Path::new(&path)).map(Some);
|
|
96
|
+
}
|
|
97
|
+
match std::env::var_os("HOME") {
|
|
98
|
+
Some(home) => Source::optional(&PathBuf::from(home).join(".pi/agent/AGENTS.md")),
|
|
99
|
+
None => Ok(None),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// Selection follows the explicit project workspace, not the roles directory,
|
|
104
|
+
/// generated .kiro directory, current shell directory, or an ancestor guess.
|
|
105
|
+
pub fn project_source(workspace: &Path) -> Result<Option<Source>, ConfigError> {
|
|
106
|
+
Source::optional(&workspace.join("AGENTS.md"))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// instruction_files is an explicit path or ordered path list. instructions is
|
|
110
|
+
/// either inline text (retained verbatim in TEAM.md) or an ordered file-list
|
|
111
|
+
/// alias. Paths are relative to TEAM.md, never the generated native config.
|
|
112
|
+
pub fn team_sources(team: &Source, meta: &Value) -> Result<Vec<Source>, ConfigError> {
|
|
113
|
+
let mut paths = Vec::new();
|
|
114
|
+
for field in ["instruction_files", "instructions"] {
|
|
115
|
+
match meta.get(field) {
|
|
116
|
+
None | Some(Value::Null) => {}
|
|
117
|
+
Some(Value::Str(_)) if field == "instructions" => {}
|
|
118
|
+
Some(Value::Str(path)) => paths.push(path),
|
|
119
|
+
Some(Value::List(values)) => {
|
|
120
|
+
for value in values {
|
|
121
|
+
match value {
|
|
122
|
+
Value::Str(path) => paths.push(path),
|
|
123
|
+
_ => {
|
|
124
|
+
return Err(ConfigError::Invalid {
|
|
125
|
+
path: team.path.clone(),
|
|
126
|
+
field,
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
_ => {
|
|
133
|
+
return Err(ConfigError::Invalid {
|
|
134
|
+
path: team.path.clone(),
|
|
135
|
+
field,
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
let mut sources = vec![team.clone()];
|
|
141
|
+
for path in paths {
|
|
142
|
+
if path.trim().is_empty() || path.contains('\0') {
|
|
143
|
+
return Err(ConfigError::Invalid {
|
|
144
|
+
path: team.path.clone(),
|
|
145
|
+
field: "instruction file path",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
let path = Path::new(path);
|
|
149
|
+
let path = if path.is_absolute() {
|
|
150
|
+
path.to_owned()
|
|
151
|
+
} else {
|
|
152
|
+
team.path
|
|
153
|
+
.parent()
|
|
154
|
+
.ok_or(ConfigError::Unsupported("TEAM.md has no parent"))?
|
|
155
|
+
.join(path)
|
|
156
|
+
};
|
|
157
|
+
sources.push(Source::read(&path)?);
|
|
158
|
+
}
|
|
159
|
+
Ok(sources)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// Framework binding precedes the four ordered user-policy layers. Original
|
|
163
|
+
/// documents (including role front matter and CR/LF bytes) are copied whole;
|
|
164
|
+
/// only section separators are new bytes. Dynamic tasks are NOT repeated here:
|
|
165
|
+
/// they enter the same native context later through the one durable outbox.
|
|
166
|
+
pub fn assemble(
|
|
167
|
+
id: &str,
|
|
168
|
+
role_name: &str,
|
|
169
|
+
mode: CommunicationMode,
|
|
170
|
+
user: Option<&Source>,
|
|
171
|
+
project: Option<&Source>,
|
|
172
|
+
role: &Source,
|
|
173
|
+
team: &[Source],
|
|
174
|
+
) -> Result<Prompt, ConfigError> {
|
|
175
|
+
let mut text = [
|
|
176
|
+
worker_identity_section(id, role_name),
|
|
177
|
+
runtime_contract_section("send_message", "report_result", "get_team_status()"),
|
|
178
|
+
mode.runtime_contract("send_message"),
|
|
179
|
+
"The operations above are logical names. Use the bound MCP client's advertised tool names, not guessed prefixes. Original role-template metadata below does not change your bound worker identity or selected native launch parameters.\nFinal completion calls report_result exactly once. Durable storage and actual leader presentation are separate facts; do not claim delivery from registration or persistence alone.".into(),
|
|
180
|
+
].join("\n\n");
|
|
181
|
+
let mut ordered = Vec::new();
|
|
182
|
+
if let Some(source) = user {
|
|
183
|
+
ordered.push((Layer::UserGlobal, source));
|
|
184
|
+
}
|
|
185
|
+
if let Some(source) = project {
|
|
186
|
+
ordered.push((Layer::Project, source));
|
|
187
|
+
}
|
|
188
|
+
ordered.push((Layer::Role, role));
|
|
189
|
+
ordered.extend(team.iter().map(|source| (Layer::TeamExplicit, source)));
|
|
190
|
+
let mut sources = Vec::new();
|
|
191
|
+
for (layer, source) in ordered {
|
|
192
|
+
text.push_str(&format!(
|
|
193
|
+
"\n\n# Team Agent instruction layer: {layer:?}\n\n"
|
|
194
|
+
));
|
|
195
|
+
let start = text.len();
|
|
196
|
+
text.push_str(&source.contents);
|
|
197
|
+
if text.len() > MAX_PROMPT_BYTES {
|
|
198
|
+
return Err(ConfigError::Unsupported(
|
|
199
|
+
"assembled prompt exceeds the bounded limit; no truncation is performed",
|
|
200
|
+
));
|
|
201
|
+
}
|
|
202
|
+
sources.push(InstructionReceipt {
|
|
203
|
+
layer,
|
|
204
|
+
path: source.path.clone(),
|
|
205
|
+
sha256: digest(source.contents.as_bytes()),
|
|
206
|
+
bytes: source.contents.len(),
|
|
207
|
+
start,
|
|
208
|
+
end: text.len(),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
Ok(Prompt {
|
|
212
|
+
sha256: digest(text.as_bytes()),
|
|
213
|
+
text,
|
|
214
|
+
sources,
|
|
215
|
+
})
|
|
216
|
+
}
|