Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 46 additions & 5 deletions crates/agentflare-backend/src/asset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ pub struct Asset {
pub created_at: i64,
pub updated_at: i64,
pub deleted_at: Option<i64>,
/// Ordinal among rows sharing (entity_type, entity_id, filename) —
/// computed at insert time in `create`, not caller-supplied.
pub version: i64,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -109,6 +112,7 @@ fn row_to_asset(row: &rusqlite::Row) -> rusqlite::Result<Asset> {
created_at: row.get(9)?,
updated_at: row.get(10)?,
deleted_at: row.get(11)?,
version: row.get(12)?,
})
}

Expand All @@ -126,9 +130,15 @@ pub fn create(conn: &Connection, input: CreateAsset) -> Result<Asset> {
},
};
let metadata = input.metadata.unwrap_or_else(|| "{}".to_string());
let version: i64 = conn.query_row(
"SELECT COALESCE(MAX(version), 0) + 1 FROM assets
WHERE entity_type = ?1 AND entity_id = ?2 AND filename = ?3 AND deleted_at IS NULL",
rusqlite::params![input.entity_type, input.entity_id, input.filename],
|r| r.get(0),
)?;
conn.execute(
"INSERT INTO assets (id, workspace_id, entity_type, entity_id, filename, size, storage_path, mime_type, metadata, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
"INSERT INTO assets (id, workspace_id, entity_type, entity_id, filename, size, storage_path, mime_type, metadata, created_at, updated_at, version)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
rusqlite::params![
id,
input.workspace_id,
Expand All @@ -141,14 +151,15 @@ pub fn create(conn: &Connection, input: CreateAsset) -> Result<Asset> {
metadata,
ts,
ts,
version,
],
)?;
get(conn, &id)
}

pub fn get(conn: &Connection, id: &str) -> Result<Asset> {
conn.query_row(
"SELECT id, workspace_id, entity_type, entity_id, filename, size, storage_path, mime_type, metadata, created_at, updated_at, deleted_at
"SELECT id, workspace_id, entity_type, entity_id, filename, size, storage_path, mime_type, metadata, created_at, updated_at, deleted_at, version
FROM assets WHERE id = ?1 AND deleted_at IS NULL",
rusqlite::params![id],
row_to_asset,
Expand All @@ -161,7 +172,7 @@ pub fn get(conn: &Connection, id: &str) -> Result<Asset> {

pub fn list_by_entity(conn: &Connection, entity_type: &str, entity_id: &str) -> Result<Vec<Asset>> {
let mut stmt = conn.prepare(
"SELECT id, workspace_id, entity_type, entity_id, filename, size, storage_path, mime_type, metadata, created_at, updated_at, deleted_at
"SELECT id, workspace_id, entity_type, entity_id, filename, size, storage_path, mime_type, metadata, created_at, updated_at, deleted_at, version
FROM assets WHERE entity_type = ?1 AND entity_id = ?2 AND deleted_at IS NULL ORDER BY created_at",
)?;
let rows = stmt.query_map(rusqlite::params![entity_type, entity_id], row_to_asset)?;
Expand All @@ -170,7 +181,7 @@ pub fn list_by_entity(conn: &Connection, entity_type: &str, entity_id: &str) ->

pub fn list_by_workspace(conn: &Connection, workspace_id: &str) -> Result<Vec<Asset>> {
let mut stmt = conn.prepare(
"SELECT id, workspace_id, entity_type, entity_id, filename, size, storage_path, mime_type, metadata, created_at, updated_at, deleted_at
"SELECT id, workspace_id, entity_type, entity_id, filename, size, storage_path, mime_type, metadata, created_at, updated_at, deleted_at, version
FROM assets WHERE workspace_id = ?1 AND deleted_at IS NULL ORDER BY created_at",
)?;
let rows = stmt.query_map(rusqlite::params![workspace_id], row_to_asset)?;
Expand Down Expand Up @@ -266,10 +277,40 @@ mod tests {
.unwrap();
assert_eq!(asset.filename, "report.pdf");
assert_eq!(asset.size, 1024);
assert_eq!(asset.version, 1);
let got = get(&conn, &asset.id).unwrap();
assert_eq!(got.id, asset.id);
}

#[test]
fn reattaching_same_entity_and_filename_increments_version() {
let conn = db::open_in_memory().unwrap();
let make = |content_len: i64| CreateAsset {
workspace_id: Some("ws-1".into()),
entity_type: "item_attachment".into(),
entity_id: "item-1".into(),
filename: "handoff.md".into(),
size: content_len,
mime_type: Some("text/markdown".into()),
metadata: None,
storage_path: None,
};
let v1 = create(&conn, make(10)).unwrap();
let v2 = create(&conn, make(20)).unwrap();
assert_eq!(v1.version, 1);
assert_eq!(v2.version, 2);
// a different filename on the same entity starts its own chain at 1
let other = create(
&conn,
CreateAsset {
filename: "notes.md".into(),
..make(5)
},
)
.unwrap();
assert_eq!(other.version, 1);
}

#[test]
fn write_and_read_file() {
let dir = tempfile::tempdir().unwrap();
Expand Down
1 change: 1 addition & 0 deletions crates/agentflare-backend/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use std::path::Path;
const MIGRATION_LIST: &[M<'static>] = &[
M::up(include_str!("migrations/0001_initial.sql")),
M::up(include_str!("migrations/0002_schema_constraints.sql")),
M::up(include_str!("migrations/0003_asset_versioning.sql")),
];
const MIGRATIONS: Migrations = Migrations::from_slice(MIGRATION_LIST);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Each attach on the same (entity_type, entity_id, filename) is a new,
-- immutable row rather than an in-place update — version is just its
-- ordinal within that group, computed at insert time in asset::create.
ALTER TABLE assets ADD COLUMN version INTEGER NOT NULL DEFAULT 1;
50 changes: 33 additions & 17 deletions src/mcp_prompts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,11 @@ fn get_artifact_command(request: &GetPromptRequestParams) -> GetPromptResult {
// across versions (/agentflare:artifact vs /mcp__agentflare__artifact),
// so the usage card only shows the argument part.
return assistant_text(
"Artifact commands (live-shareable local pages) — pass as this command's argument:\n\
"Artifact commands (live-shareable local pages) — pass as this command's argument.\n\
Deprecated for agent-to-agent handoffs: `/handoff` now assigns items and attaches \
content as versioned assets instead of publishing artifacts. This command remains \
for standalone shareable pages (dashboards, reports) — kept for reference/backward \
compatibility, not the recommended path for new agent-to-agent work.\n\
publish [--name N] [--type html|markdown|mermaid|diagram|text] [--session S] [--label L] [--description D] [--favicon 🚀] — publish preceding/attached content\n\
update <id> [--base-version N] [options] — update in place (open tabs live-reload)\n\
list [--session S]\n\
Expand All @@ -157,6 +161,8 @@ fn get_artifact_command(request: &GetPromptRequestParams) -> GetPromptResult {

assistant_text(format!(
"Artifact command requested: `{command}`\n\n\
Deprecated for agent-to-agent handoffs (use `/handoff` instead); still fine for \
standalone shareable pages.\n\n\
Parse the subcommand and options, then execute with the agentflare MCP tools \
(load via ToolSearch if deferred):\n\
- publish → artifact_publish; content is the inline content if given, otherwise \
Expand Down Expand Up @@ -185,11 +191,11 @@ fn get_handoff_command(request: &GetPromptRequestParams, agent: Option<&str>) ->

if command.is_empty() {
return assistant_text(format!(
"Handoff — agent-to-agent work exchange via artifacts. Pass as this command's argument:\n\
"Handoff — agent-to-agent work exchange via items and assets. Pass as this command's argument:\n\
<recipient> <brief> — hand the relevant work product to that agent (e.g. `codex review the API design above`)\n\
inbox [me] — list artifacts addressed to an agent (default: {me})\n\
thread <id> — show a handoff thread's artifacts in order\n\
Work products only — facts and decisions belong in memory (memory_remember), not artifacts.",
inbox [me] — list this project's tasks assigned to (or unclaimed for) an agent (default: {me})\n\
thread <id> — show a handoff thread's items in order\n\
Work products only — facts and decisions belong in memory (memory_remember), not items.",
));
}

Expand All @@ -199,17 +205,24 @@ fn get_handoff_command(request: &GetPromptRequestParams, agent: Option<&str>) ->
- `<recipient> <brief>` → call the `handoff` tool with recipient=<recipient>, \
name from the brief, content = the work product the brief points at (the preceding \
conversation content, diff, review, or document — ask only if genuinely ambiguous), \
and a thread_id when continuing an exchange. Prepend the brief to the content so the \
recipient knows what is being asked (sender is set to your identity, {me}, \
automatically). Use the `handoff` tool, not artifact_publish, so recipient can't be \
omitted. When answering an item from your inbox, set reply_to=<that artifact id> and \
reuse its thread_id.\n\
- `inbox [me]` → artifact_list with recipient=<me or {me}>; summarize sender, \
name, and brief for each.\n\
- `thread <id>` → artifact_list with thread_id=<id>; present in chronological order with \
reply lineage.\n\
Report the resulting URL (or listing) afterwards. Work products only — facts/decisions \
go to memory (memory_remember), not artifacts."
and a thread_id when continuing an exchange. This assigns/creates an item for the \
recipient and attaches the content to it as a versioned asset — prepend the brief to \
the content so the recipient knows what is being asked (sender is set to your \
identity, {me}, automatically). Use the `handoff` tool, not a bare item update, so \
recipient can't be omitted. When answering an item from your inbox, set \
item_id=<that item's id> (so the reply becomes the next asset version instead of a new \
item) and reply_to=<id of the specific message you're answering>, reusing its \
thread_id.\n\
- `inbox [me]` → call the `item` tool (action=list; already scoped to this repo's \
linked project) and filter to items where assignee_agent is <me or {me}> or unassigned; \
summarize name, state, and brief per item. Pull an item's full content only if you need \
it, via the `asset` tool (action=list, item_id=<id>) and asset get on the latest \
version.\n\
- `thread <id>` → call `item` (action=list), filter client-side to items whose \
metadata.thread matches <id>, then pull each item's assets (asset tool) for content; \
present in chronological order with reply lineage.\n\
Report the resulting listing afterwards. Work products only — facts/decisions go to \
memory (memory_remember), not items."
))
}

Expand Down Expand Up @@ -315,7 +328,10 @@ mod tests {
let result = get_prompt(&params, Some("opencode")).unwrap();
let text = format!("{:?}", result.messages[0].content);
assert!(text.contains("identity, opencode"), "{text}");
assert!(text.contains("recipient=<me or opencode>"), "{text}");
assert!(
text.contains("assignee_agent is <me or opencode>"),
"{text}"
);
assert!(!text.contains("claude-code"), "{text}");
}

Expand Down
Loading
Loading