makepad/libs/asset/chat/src/dispatch.rs
Arena Agent d8d5ea8b6a Repair integration manifests and port nigig exports onto upstream widget families
Restore missing required asset/flow packages from the pinned nigig revision; retain upstream script isolation, X11 hover, and widget-core fixes. Validated makepad-widgets data feature with Rust 1.97.1.
2026-10-26 11:23:26 +00:00

395 lines
17 KiB
Rust

//! Catalog READS of the content tool allowlist against a real Asset Server,
//! through the hardened `makepad-asset-client` API: asset.search,
//! asset.inspect, and honest, typed refusals for everything the store no
//! longer executes (aicore §9 — generation and transforms run in the
//! creating app; `makepad-asset-creator`'s CreatorTools wraps this executor
//! and owns them). Error text is redacted before it can reach a model or a
//! transcript; the bearer token never appears on the chat wire.
use crate::session::{CancelFlag, ExecCtx, ToolExecutor};
use crate::tools::{ContentToolCall, InspectTarget};
use crate::wire::{sanitize_public_error, ToolOutcome};
use makepad_asset_client::error::{ClientError, ClientResult};
use makepad_asset_client::json::{self, Value};
use makepad_asset_client::{Api, ApiEndpoints, CatalogQuery, HttpLimits};
use makepad_asset_data::{AssetKind, AssetManifest, AssetRevisionId};
pub struct AssetServerTools {
api: Api,
/// Namespace this gateway reads as its working project.
namespace: String,
}
impl AssetServerTools {
/// Connect with the SERVER-side bearer token (broker or service
/// deployment) and the namespace operations are created in. UI clients
/// never construct this.
pub fn connect(
endpoints: ApiEndpoints,
token: Option<String>,
namespace: impl Into<String>,
) -> ClientResult<AssetServerTools> {
let api = Api::new(endpoints, HttpLimits::default_v1(), token)?;
Ok(AssetServerTools { api, namespace: namespace.into() })
}
// ------------------------------------------------------------ executors
fn asset_search(&self, query: &str, limit: u32) -> ToolOutcome {
let mut q = CatalogQuery::browse(limit);
q.text = query.to_string();
match self.api.catalog_search(&q, None) {
Err(e) => err_outcome(e),
Ok(page) => ToolOutcome::Ok {
value: json::obj(vec![(
"hits",
Value::Arr(
page.hits
.iter()
.map(|h| {
let mut pairs = vec![
("asset", json::s(h.asset_id.to_string())),
("title", json::s(h.title.clone())),
("namespace", json::s(h.namespace.clone())),
];
if let Some(k) = h.kind {
pairs.push(("kind", json::s(kind_label(k))));
}
if let Some(a) = &h.alias {
pairs.push(("alias", json::s(a.to_string())));
}
json::obj(pairs)
})
.collect(),
),
)]),
},
}
}
/// One bounded SELECT over the live catalog, via the server's
/// `/v1/assets/query` route (the server owns the row/step/deadline
/// budgets and the single-SELECT guard). Rendered exactly like the old
/// broker did — aligned text plus a row count — so the model reads it
/// the same way it always has.
fn assets_query(&self, sql: &str) -> ToolOutcome {
match self.api.assets_query(sql) {
Err(e) => err_outcome(e),
Ok(dto) => query_outcome(dto),
}
}
/// The catalog schema, fetched from the server's own `sqlite_master`
/// through the same bounded query route (there is no dedicated schema
/// endpoint), plus the usage notes the model needs to write good SQL.
fn assets_schema(&self) -> ToolOutcome {
const SCHEMA_SQL: &str = "SELECT name, sql FROM sqlite_master WHERE name IN \
('assets', 'asset_aliases', 'asset_revisions', 'search_annotations', \
'search_labels') ORDER BY name";
match self.api.assets_query(SCHEMA_SQL) {
Err(e) => err_outcome(e),
Ok(dto) => {
let mut text =
String::from("Catalog tables (SELECT-only; single statement):\n");
for row in &dto.rows {
if let Some(sql) = row.get(1) {
text.push_str(sql);
text.push('\n');
}
}
text.push_str(crate::catalog_sql::SCHEMA_NOTES);
ToolOutcome::Ok {
value: json::obj(vec![("text", json::s(text))]),
}
}
}
}
fn inspect(&self, target: &InspectTarget) -> ToolOutcome {
match target {
InspectTarget::Revision(rev) => self.revision_summary(rev),
InspectTarget::Alias(alias) => match self.api.resolve_alias(alias) {
Err(e) => err_outcome(e),
Ok(dto) => ToolOutcome::Ok {
value: json::obj(vec![
("alias", json::s(dto.alias.to_string())),
("asset", json::s(dto.asset_id.to_string())),
("revision", json::s(dto.head_revision.to_string())),
]),
},
},
InspectTarget::Asset(id) => match self.api.asset_detail(id) {
Err(e) => err_outcome(e),
Ok(detail) => ToolOutcome::Ok {
value: json::obj(vec![
("asset", json::s(detail.asset_id.to_string())),
("namespace", json::s(detail.namespace.clone())),
(
"candidates",
Value::Arr(
detail
.candidates
.iter()
.map(|c| {
json::obj(vec![
("revision", json::s(c.revision.to_string())),
("state", json::s(c.state.as_str())),
])
})
.collect(),
),
),
]),
},
},
}
}
fn revision_summary(&self, rev: &AssetRevisionId) -> ToolOutcome {
let bytes = match self.api.fetch_revision_bytes(rev) {
Ok(b) => b,
Err(e) => return err_outcome(e),
};
let manifest = match AssetManifest::from_canonical_bytes(&bytes) {
Ok(m) => m,
Err(e) => {
return ToolOutcome::Failed { message: public_error(&format!("manifest decode: {e}")) }
}
};
let mut pairs = vec![
("revision", json::s(rev.to_string())),
("asset", json::s(manifest.asset_id.to_string())),
("kind", json::s(kind_label(manifest.kind))),
("files", Value::Int(manifest.files.len() as i64)),
];
if let Some(p) = &manifest.provenance {
pairs.push((
"parents",
Value::Arr(p.parents.iter().map(|r| json::s(r.to_string())).collect()),
));
}
ToolOutcome::Ok { value: json::obj(pairs) }
}
}
impl ToolExecutor for AssetServerTools {
fn capability_doc(&mut self) -> String {
format!(
"Catalog tools over namespace '{}': asset.search, asset.inspect, assets.query. \
Generation and asset transforms run in the creator apps, not through this \
dispatcher.",
self.namespace
)
}
fn execute(
&mut self,
call: &ContentToolCall,
_ctx: &ExecCtx,
_progress: &mut dyn FnMut(u16, &str),
_cancel: &CancelFlag,
) -> ToolOutcome {
match call {
ContentToolCall::ImageGenerate { .. }
| ContentToolCall::VideoGenerate { .. }
| ContentToolCall::AudioGenerate { .. }
| ContentToolCall::SpeechGenerate { .. }
| ContentToolCall::MusicGenerate { .. }
| ContentToolCall::MeshGenerate { .. }
| ContentToolCall::WorldGenerate { .. }
| ContentToolCall::CharacterGenerate { .. } => ToolOutcome::Unavailable {
reason: "Generation needs an owned creator pipeline; the store dispatcher does not start jobs.".into(),
},
// Generation and transforms run in the creating app (aicore §9);
// CreatorTools intercepts these before this executor ever sees
// them, and a direct caller gets the same honest answer.
ContentToolCall::ContentGenerate { .. } => ToolOutcome::Unavailable {
reason: "content generation runs in the creator apps (makepad-asset-creator); \
the store only stores"
.to_string(),
},
ContentToolCall::DefaultsGet
| ContentToolCall::DefaultsSet { .. }
| ContentToolCall::FleetIntrospect { .. } => ToolOutcome::Unavailable {
reason: "defaults and fleet introspection are owned by the AI Content chat session"
.into(),
},
ContentToolCall::AssetSearch { query, limit } => self.asset_search(query, *limit),
ContentToolCall::AssetInspect { target } => self.inspect(target),
ContentToolCall::OperationCapabilities
| ContentToolCall::OperationCreate { .. }
| ContentToolCall::OperationGet { .. }
| ContentToolCall::OperationWait { .. }
| ContentToolCall::OperationCancel { .. }
| ContentToolCall::OperationRetry { .. } => ToolOutcome::Unavailable {
reason: "asset transforms run in the creator apps now (the store only \
stores); ask the person to run the enhancement from their app"
.to_string(),
},
ContentToolCall::LlmConsult { .. } => ToolOutcome::Unavailable {
reason: "llm.consult is executed by the chat broker".to_string(),
},
// Catalog SQL runs HERE, over the same authenticated client the
// search uses. The game session advertises assets.query/schema
// (see `tools::sandbox_definitions`) and, since the session
// engine moved in-process (aicore P8), this executor is the one
// that answers them — the old "the broker runs it" refusal left
// the game AI's main alias-search tool permanently dead
// (observed live 2026-09-01: every doom-enemy lookup answered
// "Unavailable" and the model gave up on the store).
ContentToolCall::AssetsQuery { sql } => self.assets_query(sql),
ContentToolCall::AssetsSchema => self.assets_schema(),
// Game-client tools: the broker's dispatcher never advertises
// them (see `ToolExecutor::tool_definitions`); a model that
// calls one anyway gets the honest answer, not an execution.
ContentToolCall::ModelBuild { .. }
| ContentToolCall::ModelFetch { .. }
| ContentToolCall::WorldPlace { .. }
| ContentToolCall::WorldRemove { .. }
| ContentToolCall::WorldMove { .. }
| ContentToolCall::WorldList
| ContentToolCall::WorldGetSource
| ContentToolCall::WorldApi { .. }
| ContentToolCall::WorldGetPlan
| ContentToolCall::WorldSetPlan { .. }
| ContentToolCall::WorldSetSource { .. }
| ContentToolCall::WorldNewLevel { .. }
| ContentToolCall::WorldSetPlayerModel { .. }
| ContentToolCall::WorldSpawn { .. }
| ContentToolCall::WorldTune { .. }
| ContentToolCall::WorldAddAddon { .. }
| ContentToolCall::WorldInSub { .. } => ToolOutcome::Unavailable {
reason: "local model and world tools run in a game chat session".to_string(),
},
}
}
}
/// Typed error mapping: authorization failures are `Denied` (the ACL
/// answer), everything else is an operational failure with a bounded
/// description. Never a panic, never a silent Ok.
/// Render one bounded query result the way the broker always has: aligned
/// text with a row-count footer, plus the machine-readable row count the
/// chat UI's chip reads (`outcome_summary` shows "queried → N rows").
fn query_outcome(dto: makepad_asset_client::dto::AssetsQueryDto) -> ToolOutcome {
let out = crate::catalog_sql::QueryOutput {
columns: dto.columns,
rows: dto.rows,
truncated: dto.truncated,
elapsed_ms: dto.elapsed_ms,
};
ToolOutcome::Ok {
value: json::obj(vec![
("rows", Value::Int(out.rows.len() as i64)),
("truncated", Value::Bool(out.truncated)),
("text", json::s(out.to_text())),
]),
}
}
fn err_outcome(e: ClientError) -> ToolOutcome {
match e {
ClientError::Unauthenticated => {
ToolOutcome::Denied { what: "not authenticated to the asset server".to_string() }
}
ClientError::Denied => {
ToolOutcome::Denied { what: "the asset server denied this operation".to_string() }
}
ClientError::NotFound { what } => {
ToolOutcome::Failed { message: public_error(&format!("not found: {what}")) }
}
ClientError::Server { status: 409, detail } => ToolOutcome::Refused {
what: public_error(&format!(
"the asset server refused this state transition: {}",
detail.as_deref().unwrap_or("conflict")
)),
},
other => ToolOutcome::Failed { message: public_error(&other.to_string()) },
}
}
fn public_error(message: &str) -> String {
sanitize_public_error(message)
}
pub(crate) fn kind_label(kind: AssetKind) -> &'static str {
match kind {
AssetKind::Mesh => "mesh",
AssetKind::Character => "character",
AssetKind::Weapon => "weapon",
AssetKind::Vehicle => "vehicle",
AssetKind::Prop => "prop",
AssetKind::Texture => "texture",
AssetKind::Material => "material",
AssetKind::Audio => "audio",
AssetKind::Video => "video",
AssetKind::Skybox => "skybox",
AssetKind::World => "world",
AssetKind::Prefab => "prefab",
AssetKind::Billboard => "billboard",
AssetKind::Game => "game",
AssetKind::VjEffect => "vjeffect",
AssetKind::Data => "data",
AssetKind::ModelProgram => "model-program",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_no_credential(s: &str) {
assert!(!s.to_ascii_lowercase().contains("bearer"), "{s}");
assert!(!s.contains("mpat_"), "{s}");
}
/// Error text that could carry a bearer header is redacted before it
/// reaches a model or a transcript.
#[test]
fn tool_errors_redact_bearer_mpat() {
match err_outcome(ClientError::Server {
status: 500,
detail: Some("Authorization: Bearer mpat_LEAK123".into()),
}) {
ToolOutcome::Failed { message } => {
assert_eq!(message, "provider error");
assert_no_credential(&message);
}
other => panic!("expected Failed, got {other:?}"),
}
match err_outcome(ClientError::Server {
status: 409,
detail: Some("Authorization: Bearer mpat_LEAK123".into()),
}) {
ToolOutcome::Refused { what } => assert_no_credential(&what),
other => panic!("expected Refused, got {other:?}"),
}
}
/// assets.query answers with rendered rows the model can read AND the
/// row count the UI chip reads — not the old "Unavailable" refusal
/// (which left the game session's advertised SQL tool permanently
/// dead; the live doom-enemy hunt died on it, 2026-09-01).
#[test]
fn assets_query_outcome_renders_rows_and_counts() {
let dto = makepad_asset_client::dto::AssetsQueryDto {
columns: vec!["canon_alias".into(), "kind".into()],
rows: vec![
vec!["doom/doom/billboards/doom1/troo".into(), "billboard".into()],
vec!["doom/doom/billboards/doom1/sarg".into(), "billboard".into()],
],
truncated: false,
elapsed_ms: 3,
};
match query_outcome(dto) {
ToolOutcome::Ok { value } => {
assert_eq!(value.get("rows").and_then(Value::as_i64), Some(2));
let text = value.get("text").and_then(Value::as_str).unwrap();
assert!(text.contains("canon_alias"), "{text}");
assert!(text.contains("doom/doom/billboards/doom1/troo"), "{text}");
assert!(text.contains("(2 rows)"), "{text}");
}
other => panic!("expected Ok, got {other:?}"),
}
}
}