feat(assistant): content_list gains scope — peers, purchased and IndeeHub films
Operator asked "what films are there to watch from my peers" and the model answered, honestly, that it had no tool for it. It was right: content_list mapped only to content.list-mine — this node's own shared files. Peer catalogues and IndeeHub were unreachable from the assistant entirely. content_list now takes scope: own | peers | purchased | films, dispatching to content.list-mine / content.browse-all-peers / content.owned-list / content.indeehub-projects. The model picks from a closed enum and never names a method, so an invented scope falls back to "own" rather than reaching anything it was not granted (T-13-34). Two new RPCs behind it: - content.browse-all-peers aggregates every federated peer in ONE call. The dashboard fans this out client-side, but asking a model to enumerate peers and loop is how it ends up claiming it has no tool. Rides FIPS — PeerRequest::new(fips_npub, onion, "/content") with a 6s FIPS fast-fail then Tor — so the onion is the peer's identity and FIPS is the transport. Sequential with a per-peer timeout, not an unbounded fan-out: 02-08 traced a real UI stall to browse-peer starving the connection pool. One peer being down is the normal case and contributes nothing rather than failing the call. - content.indeehub-projects fetches IndeeHub's catalogue, public plus (via a node-signed NIP-98 login) the operator's private titles. Node-side because signing that in the browser would put identity material next to the model, which this phase rules out by name. Tolerant of IndeeHub's field spellings across versions, and absent/stopped/empty all yield an empty list rather than failing the caller. action_key includes the scope, so listing peers cannot be replayed as listing own files. 15/15 assistant::tools. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
05b459a65f
commit
58c759c149
@@ -254,6 +254,9 @@ fn canonical_args(args: &ToolArgs) -> String {
|
||||
ToolArgs::AppLogs(a) => json!({ "app_id": a.app_id, "lines": a.lines }).to_string(),
|
||||
ToolArgs::SettingsGet(a) => json!({ "key": a.key }).to_string(),
|
||||
ToolArgs::SettingsSet(a) => json!({ "key": a.key, "value": a.value }).to_string(),
|
||||
// Scope is part of the identity: listing peers is a different action
|
||||
// from listing this node's own files, so they must not share a key.
|
||||
ToolArgs::ContentList(a) => json!({ "scope": a.scope }).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -195,6 +195,16 @@ pub enum ToolArgs {
|
||||
AppLogs(AppLogsArgs),
|
||||
SettingsGet(SettingsGetArgs),
|
||||
SettingsSet(SettingsSetArgs),
|
||||
ContentList(ContentListArgs),
|
||||
}
|
||||
|
||||
/// `content_list`'s only argument. A closed enum on the wire, defaulted here,
|
||||
/// so a model that omits it or invents a value gets "own" rather than an
|
||||
/// error — the question "what content is there" is always answerable.
|
||||
#[derive(Debug, Default, serde::Deserialize)]
|
||||
pub struct ContentListArgs {
|
||||
#[serde(default)]
|
||||
pub scope: Option<String>,
|
||||
}
|
||||
|
||||
impl ToolDef {
|
||||
@@ -204,8 +214,11 @@ impl ToolDef {
|
||||
/// model can recover from (AI-SPEC §4b.1).
|
||||
pub fn validate(&self, raw: &Value) -> Result<ToolArgs> {
|
||||
match self.name {
|
||||
"content_list" => serde_json::from_value(raw.clone())
|
||||
.map(ToolArgs::ContentList)
|
||||
.context("tool arguments did not match the declared schema"),
|
||||
"system_disk_status" | "system_stats" | "apps_list" | "bitcoin_status"
|
||||
| "network_status" | "mesh_status" | "content_list" => {
|
||||
| "network_status" | "mesh_status" => {
|
||||
serde_json::from_value(raw.clone())
|
||||
.map(ToolArgs::Empty)
|
||||
.context("tool arguments did not match the declared schema")
|
||||
@@ -352,10 +365,16 @@ pub fn mesh_status_tool() -> ToolDef {
|
||||
pub fn content_list_tool() -> ToolDef {
|
||||
ToolDef {
|
||||
name: "content_list",
|
||||
description: "List content this node is currently sharing (filename, mime type, size, access level). Never returns file contents.",
|
||||
description: "List films, media and files available to this node. Use scope to choose WHERE to look: \"own\" = shared by this node, \"peers\" = shared by federated peer nodes, \"purchased\" = paid items this node owns, \"films\" = the IndeeHub film catalogue. Use this to answer questions about what there is to watch, listen to, or read — including films from peers. Never returns file contents.",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"properties": {
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"enum": ["own", "peers", "purchased", "films"],
|
||||
"description": "Where to look. Defaults to \"own\".",
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
}),
|
||||
category: PermissionCategory::Media,
|
||||
@@ -679,8 +698,22 @@ pub async fn dispatch(name: &str, args: &ToolArgs, handler: &RpcHandler) -> Resu
|
||||
.assistant_dispatch_tool("mesh.status", None)
|
||||
.await
|
||||
.map_err(|e| format!("tool execution failed: {e}")),
|
||||
// Scope decides the RPC. The model picks a value from a closed enum;
|
||||
// it never names a method, so an invented scope falls back to "own"
|
||||
// rather than reaching anything it was not granted (T-13-34).
|
||||
"content_list" => handler
|
||||
.assistant_dispatch_tool("content.list-mine", None)
|
||||
.assistant_dispatch_tool(
|
||||
match args {
|
||||
ToolArgs::ContentList(a) => match a.scope.as_deref() {
|
||||
Some("peers") => "content.browse-all-peers",
|
||||
Some("purchased") => "content.owned-list",
|
||||
Some("films") => "content.indeehub-projects",
|
||||
_ => "content.list-mine",
|
||||
},
|
||||
_ => "content.list-mine",
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("tool execution failed: {e}")),
|
||||
"settings_get" => {
|
||||
@@ -822,6 +855,37 @@ pub async fn dispatch(name: &str, args: &ToolArgs, handler: &RpcHandler) -> Resu
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn content_list_accepts_every_scope_the_schema_advertises() {
|
||||
// The schema promises these four. If validate() rejected one, the model
|
||||
// would be told to use a value that then errors — the worst failure
|
||||
// mode, because it looks like the model is wrong.
|
||||
let def = content_list_tool();
|
||||
for scope in ["own", "peers", "purchased", "films"] {
|
||||
assert!(
|
||||
def.validate(&json!({ "scope": scope })).is_ok(),
|
||||
"advertised scope {scope} was rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_list_without_arguments_still_validates() {
|
||||
// "what films are there" should never fail because the model omitted
|
||||
// an optional argument.
|
||||
assert!(content_list_tool().validate(&json!({})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_list_scopes_are_distinct_actions() {
|
||||
// Listing peers is not the same action as listing this node's own
|
||||
// files; sharing an action_key would let one be replayed as the other.
|
||||
use crate::assistant::confirm::action_key;
|
||||
let own = content_list_tool().validate(&json!({ "scope": "own" })).unwrap();
|
||||
let peers = content_list_tool().validate(&json!({ "scope": "peers" })).unwrap();
|
||||
assert_ne!(action_key("content_list", &own), action_key("content_list", &peers));
|
||||
}
|
||||
use super::*;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::assistant::backends::scripted::ScriptedBackend;
|
||||
|
||||
Reference in New Issue
Block a user