use std::collections::BTreeSet;
use ciborium::Value;
const DW_AGENT_COMPONENT_ID: &str = "dw.agent";
pub(crate) fn referenced_dw_agents(flow_manifests: &[&[u8]]) -> Vec<(String, String, String)> {
let mut refs = Vec::new();
for bytes in flow_manifests {
let Ok(manifest) = ciborium::de::from_reader::<Value, _>(*bytes) else {
continue;
};
extract_dw_agent_refs(&manifest, &mut refs);
}
refs
}
fn extract_dw_agent_refs(manifest: &Value, refs: &mut Vec<(String, String, String)>) {
let component_ids = symbol_table(manifest, "component_ids");
let node_ids = symbol_table(manifest, "node_ids");
let Some(Value::Array(flows)) = map_get(manifest, "flows") else {
return;
};
for flow_entry in flows {
let flow_id = map_get(flow_entry, "id")
.and_then(as_text)
.unwrap_or_else(|| "<unknown-flow>".to_string());
let Some(inner) = map_get(flow_entry, "flow") else {
continue;
};
let Some(Value::Array(nodes)) = map_get(inner, "nodes") else {
continue;
};
for node in nodes {
let Some(component) = map_get(node, "component") else {
continue;
};
let Some(component_id) = resolve_component_id(component, &component_ids) else {
continue;
};
if component_id != DW_AGENT_COMPONENT_ID {
continue;
}
let Some(agent_id) = map_get(component, "operation").and_then(as_text) else {
continue;
};
if agent_id.is_empty() {
continue;
}
let node_id = resolve_node_id(node, &node_ids);
refs.push((flow_id.clone(), node_id, agent_id));
}
}
}
pub(crate) fn provided_agent_ids(sidecars: &[&[u8]]) -> BTreeSet<String> {
let mut ids = BTreeSet::new();
for bytes in sidecars {
let Ok(json_value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
continue;
};
let Some(agent_map) = json_value.as_object() else {
continue;
};
for key in agent_map.keys() {
if !key.is_empty() {
ids.insert(key.clone());
}
}
}
ids
}
pub fn auto_wire_agent_packs(
def: &super::BundleWorkspaceDefinition,
flow_manifests: &[&[u8]],
provided_sidecars: &[&[u8]],
packs_dir: &std::path::Path,
cache_dir: &std::path::Path,
offline: bool,
trust: &greentic_distributor_client::signing::TrustRoot,
) -> anyhow::Result<Vec<String>> {
use anyhow::Context as _;
let referenced = referenced_dw_agents(flow_manifests);
let mut provided = provided_agent_ids(provided_sidecars);
if packs_dir.is_dir()
&& let Ok(entries) = std::fs::read_dir(packs_dir)
{
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) == Some("gtpack")
&& let Ok(pack_bytes) = std::fs::read(&path)
&& let Some(sidecar_bytes) =
read_zip_entry_from_bytes(&pack_bytes, "dw-agents.json")
{
provided.extend(provided_agent_ids(&[sidecar_bytes.as_slice()]));
}
}
}
let mut missing_by_id: std::collections::BTreeMap<String, String> =
std::collections::BTreeMap::new();
for (flow_id, _node_id, agent_id) in &referenced {
if !provided.contains(agent_id) {
missing_by_id
.entry(agent_id.clone())
.or_insert_with(|| flow_id.clone());
}
}
if missing_by_id.is_empty() {
return Ok(Vec::new());
}
let mut coordinate_to_agents: std::collections::BTreeMap<String, Vec<String>> =
std::collections::BTreeMap::new();
for (agent_id, flow_id) in &missing_by_id {
let coordinate = def.agent_packs.get(agent_id).ok_or_else(|| {
anyhow::anyhow!(
"agent \"{agent_id}\" referenced by flow \"{flow_id}\" is not provided \
and has no agent_packs mapping — add \
agent_packs.{agent_id} = store://\u{2026} or include its pack"
)
})?;
coordinate_to_agents
.entry(coordinate.clone())
.or_default()
.push(agent_id.clone());
}
let store_base = std::env::var("GREENTIC_STORE_URL")
.unwrap_or_else(|_| "https://store.greentic.cloud".to_string());
std::fs::create_dir_all(packs_dir)
.with_context(|| format!("create packs_dir {}", packs_dir.display()))?;
let mut materialized: Vec<String> = Vec::new();
for (coordinate, expected_agent_ids) in &coordinate_to_agents {
let pack_bytes = fetch_coordinate_bytes(coordinate, &store_base, cache_dir, offline, trust)
.with_context(|| format!("resolve agent pack coordinate {coordinate}"))?;
let sidecar_opt = read_zip_entry_from_bytes(&pack_bytes, "dw-agents.json");
let actual_ids = match &sidecar_opt {
Some(bytes) => provided_agent_ids(&[bytes.as_slice()]),
None => BTreeSet::new(),
};
for agent_id in expected_agent_ids {
if !actual_ids.contains(agent_id) {
anyhow::bail!(
"agent pack at {coordinate} does not provide agent \"{agent_id}\" \
— expected it in dw-agents.json sidecar but the pack provides: [{}]",
actual_ids.iter().cloned().collect::<Vec<_>>().join(", ")
);
}
}
let filename = pack_filename_for_coordinate(coordinate);
let dest = packs_dir.join(&filename);
std::fs::write(&dest, &pack_bytes)
.with_context(|| format!("write agent pack to {}", dest.display()))?;
materialized.extend(expected_agent_ids.iter().cloned());
}
materialized.sort();
materialized.dedup();
Ok(materialized)
}
fn fetch_coordinate_bytes(
coordinate: &str,
store_base: &str,
cache_dir: &std::path::Path,
offline: bool,
trust: &greentic_distributor_client::signing::TrustRoot,
) -> anyhow::Result<Vec<u8>> {
use anyhow::Context as _;
use greentic_distributor_client::store_agentic_worker::fetch_store_agentic_worker_verified;
if let Some(name_version) = coordinate.strip_prefix("store://") {
let (name, version) = parse_store_name_version(name_version)
.with_context(|| format!("parse store coordinate store://{name_version}"))?;
return fetch_store_agentic_worker_verified(
store_base, &name, &version, cache_dir, offline, trust,
)
.with_context(|| format!("fetch store agentic worker {name}@{version}"));
}
if let Some(path_str) = coordinate.strip_prefix("file://") {
let path = std::path::PathBuf::from(path_str);
return std::fs::read(&path)
.with_context(|| format!("read agent pack from {}", path.display()));
}
anyhow::bail!(
"unsupported agent pack coordinate scheme: \"{coordinate}\" \
(expected store:// or file://)"
)
}
fn parse_store_name_version(name_version: &str) -> anyhow::Result<(String, String)> {
let (name, version) = name_version.split_once('@').ok_or_else(|| {
anyhow::anyhow!(
"store coordinate must be in the form <name>@<version>, got: \"{name_version}\""
)
})?;
if name.is_empty() {
anyhow::bail!("store coordinate name is empty in \"{name_version}\"");
}
if version.is_empty() {
anyhow::bail!("store coordinate version is empty in \"{name_version}\"");
}
Ok((name.to_string(), version.to_string()))
}
fn pack_filename_for_coordinate(coordinate: &str) -> String {
if let Some(name_version) = coordinate.strip_prefix("store://") {
let name = name_version.split('@').next().unwrap_or(name_version);
return format!("{name}.gtpack");
}
if let Some(path_str) = coordinate.strip_prefix("file://") {
let path = std::path::Path::new(path_str);
if let Some(filename) = path.file_name() {
let s = filename.to_string_lossy();
if !s.ends_with(".gtpack") {
return format!("{s}.gtpack");
}
return s.into_owned();
}
}
let sanitized = coordinate
.replace(['/', '\\', ':', '@', ' '], "_")
.trim_matches('_')
.to_string();
format!("{sanitized}.gtpack")
}
pub(crate) fn read_zip_entry_from_bytes(pack_bytes: &[u8], entry_name: &str) -> Option<Vec<u8>> {
use std::io::{Cursor, Read};
let cursor = Cursor::new(pack_bytes);
let mut archive = zip::ZipArchive::new(cursor).ok()?;
let mut entry = archive.by_name(entry_name).ok()?;
let mut buf = Vec::new();
entry.read_to_end(&mut buf).ok()?;
Some(buf)
}
fn map_get<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
let Value::Map(map) = value else {
return None;
};
map.iter()
.find(|(k, _)| matches!(k, Value::Text(t) if t == key))
.map(|(_, v)| v)
}
fn as_text(value: &Value) -> Option<String> {
match value {
Value::Text(text) => Some(text.clone()),
_ => None,
}
}
fn as_index(value: &Value) -> Option<usize> {
if let Value::Integer(int) = value {
let n: i128 = (*int).into();
usize::try_from(n).ok()
} else {
None
}
}
fn symbol_table(manifest: &Value, name: &str) -> Vec<String> {
let Some(symbols) = map_get(manifest, "symbols") else {
return Vec::new();
};
let Some(Value::Array(items)) = map_get(symbols, name) else {
return Vec::new();
};
items.iter().filter_map(as_text).collect()
}
fn resolve_component_id(component: &Value, component_ids: &[String]) -> Option<String> {
let id = map_get(component, "id")?;
if let Some(text) = as_text(id) {
return Some(text);
}
let index = as_index(id)?;
component_ids.get(index).cloned()
}
fn resolve_node_id(node: &Value, node_ids: &[String]) -> String {
match map_get(node, "id") {
Some(value) => {
if let Some(text) = as_text(value) {
return text;
}
if let Some(index) = as_index(value) {
if let Some(name) = node_ids.get(index) {
return name.clone();
}
return format!("#{index}");
}
"<unknown-node>".to_string()
}
None => "<unknown-node>".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cbor_map(pairs: Vec<(&str, Value)>) -> Value {
Value::Map(
pairs
.into_iter()
.map(|(k, v)| (Value::Text(k.to_string()), v))
.collect(),
)
}
fn cbor_array(items: Vec<Value>) -> Value {
Value::Array(items)
}
fn text(s: &str) -> Value {
Value::Text(s.to_string())
}
fn int(n: i64) -> Value {
Value::Integer(ciborium::value::Integer::from(n))
}
fn to_cbor_bytes(value: &Value) -> Vec<u8> {
let mut bytes = Vec::new();
ciborium::into_writer(value, &mut bytes).expect("CBOR serialization must succeed");
bytes
}
fn dw_agent_flow_manifest_bytes() -> Vec<u8> {
let symbols = cbor_map(vec![
(
"component_ids",
cbor_array(vec![
text("ai.greentic.component-adaptive-card"),
text("dw.agent"),
]),
),
(
"node_ids",
cbor_array(vec![text("reply"), text("research")]),
),
]);
let research_node = cbor_map(vec![
("id", int(1)),
(
"component",
cbor_map(vec![
("id", int(1)),
("operation", text("tavily_researcher")),
]),
),
]);
let reply_node = cbor_map(vec![
("id", int(0)),
(
"component",
cbor_map(vec![("id", int(0)), ("operation", text("card"))]),
),
]);
let flow_entry = cbor_map(vec![
("id", text("on_message")),
(
"flow",
cbor_map(vec![("nodes", cbor_array(vec![research_node, reply_node]))]),
),
]);
let manifest = cbor_map(vec![
("symbols", symbols),
("flows", cbor_array(vec![flow_entry])),
]);
to_cbor_bytes(&manifest)
}
#[test]
fn extracts_dw_agent_reference_with_correct_triple() {
let bytes = dw_agent_flow_manifest_bytes();
let refs = referenced_dw_agents(&[&bytes]);
assert_eq!(refs.len(), 1, "expected exactly one dw.agent reference");
assert_eq!(
refs[0],
(
"on_message".to_string(),
"research".to_string(),
"tavily_researcher".to_string()
)
);
}
#[test]
fn skips_non_dw_agent_nodes() {
let bytes = dw_agent_flow_manifest_bytes();
let refs = referenced_dw_agents(&[&bytes]);
assert!(
refs.iter()
.all(|(_, _, agent)| agent == "tavily_researcher"),
"the card node must not appear in the dw.agent references"
);
}
#[test]
fn empty_operation_node_is_skipped() {
let symbols = cbor_map(vec![
("component_ids", cbor_array(vec![text("dw.agent")])),
("node_ids", cbor_array(vec![text("research")])),
]);
let empty_op_node = cbor_map(vec![
("id", int(0)),
(
"component",
cbor_map(vec![("id", int(0)), ("operation", text(""))]),
),
]);
let flow_entry = cbor_map(vec![
("id", text("on_message")),
(
"flow",
cbor_map(vec![("nodes", cbor_array(vec![empty_op_node]))]),
),
]);
let manifest = cbor_map(vec![
("symbols", symbols),
("flows", cbor_array(vec![flow_entry])),
]);
let bytes = to_cbor_bytes(&manifest);
assert!(
referenced_dw_agents(&[&bytes]).is_empty(),
"a dw.agent node with empty operation must be skipped"
);
}
#[test]
fn multiple_manifests_are_scanned() {
let bytes_a = dw_agent_flow_manifest_bytes();
let symbols = cbor_map(vec![
("component_ids", cbor_array(vec![text("dw.agent")])),
("node_ids", cbor_array(vec![text("helper")])),
]);
let node = cbor_map(vec![
("id", int(0)),
(
"component",
cbor_map(vec![("id", int(0)), ("operation", text("demo_assistant"))]),
),
]);
let flow_entry = cbor_map(vec![
("id", text("demo_flow")),
("flow", cbor_map(vec![("nodes", cbor_array(vec![node]))])),
]);
let manifest_b = cbor_map(vec![
("symbols", symbols),
("flows", cbor_array(vec![flow_entry])),
]);
let bytes_b = to_cbor_bytes(&manifest_b);
let refs = referenced_dw_agents(&[&bytes_a, &bytes_b]);
let agents: Vec<&str> = refs.iter().map(|(_, _, a)| a.as_str()).collect();
assert!(
agents.contains(&"tavily_researcher"),
"tavily_researcher from manifest A must be present"
);
assert!(
agents.contains(&"demo_assistant"),
"demo_assistant from manifest B must be present"
);
}
#[test]
fn invalid_bytes_are_skipped() {
let bad_bytes: &[u8] = b"not cbor at all";
let refs = referenced_dw_agents(&[bad_bytes]);
assert!(refs.is_empty(), "invalid CBOR must be silently skipped");
}
fn dw_agents_sidecar_bytes(agent_ids: &[&str]) -> Vec<u8> {
let map: std::collections::BTreeMap<String, serde_json::Value> = agent_ids
.iter()
.map(|id| (id.to_string(), serde_json::json!({"kind": "placeholder"})))
.collect();
serde_json::to_vec(&map).expect("sidecar serialization must succeed")
}
#[test]
fn provided_agent_ids_extracts_keys_from_json_sidecar() {
let bytes = serde_json::to_vec(&serde_json::json!({
"tavily_researcher": {"kind": "dw-agent", "llm": "openai"},
"second_agent": {"kind": "dw-agent"}
}))
.unwrap();
let ids = provided_agent_ids(&[&bytes]);
assert!(
ids.contains("tavily_researcher"),
"tavily_researcher must be in the provided set"
);
assert!(
ids.contains("second_agent"),
"second_agent must be in the provided set"
);
assert_eq!(ids.len(), 2);
}
#[test]
fn malformed_sidecar_blob_is_skipped_gracefully() {
let bad: &[u8] = b"not json at all {{ broken";
let ids = provided_agent_ids(&[bad]);
assert!(
ids.is_empty(),
"a malformed sidecar must silently yield an empty set (no panic)"
);
}
#[test]
fn empty_json_object_yields_empty_set() {
let bytes: &[u8] = b"{}";
assert!(
provided_agent_ids(&[bytes]).is_empty(),
"an empty JSON object sidecar must yield an empty set"
);
}
#[test]
fn non_object_json_blob_yields_empty_set() {
let bytes = b"[\"some_key\"]";
assert!(
provided_agent_ids(&[bytes]).is_empty(),
"a non-object JSON blob must be skipped (contributes nothing)"
);
}
#[test]
fn multiple_sidecars_are_aggregated() {
let bytes_a = dw_agents_sidecar_bytes(&["agent_a"]);
let bytes_b = dw_agents_sidecar_bytes(&["agent_b"]);
let ids = provided_agent_ids(&[&bytes_a, &bytes_b]);
assert!(
ids.contains("agent_a"),
"agent_a from sidecar A must be present"
);
assert!(
ids.contains("agent_b"),
"agent_b from sidecar B must be present"
);
assert_eq!(ids.len(), 2);
}
#[test]
fn round_trip_guard_matches_packc_wire_format() {
let mut agents: std::collections::BTreeMap<String, serde_json::Value> =
std::collections::BTreeMap::new();
agents.insert(
"crm_assistant".to_string(),
serde_json::json!({"kind": "dw-agent", "llm_provider": "openai"}),
);
agents.insert(
"email_drafter".to_string(),
serde_json::json!({"kind": "dw-agent", "llm_provider": "anthropic"}),
);
let sidecar_bytes =
serde_json::to_vec(&agents).expect("packc-style serialization must succeed");
let ids = provided_agent_ids(&[&sidecar_bytes]);
assert!(
ids.contains("crm_assistant"),
"crm_assistant must round-trip through the packc wire format"
);
assert!(
ids.contains("email_drafter"),
"email_drafter must round-trip through the packc wire format"
);
assert_eq!(ids.len(), 2, "only the declared agents must be present");
}
}