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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to `yoagent` are documented here. The format loosely
follows [Keep a Changelog](https://keepachangelog.com/), and the project
adheres to [Semantic Versioning](https://semver.org/).

## Unreleased

### yoagent-rutis

- **Images in tool results, across ecosystems.** TypeScript and Python handlers' `call_tool` results and `after_tool` edits take `content` blocks in yoagent's JSON shape (`{"type": "image", "data": <base64>, "mimeType"}` next to text blocks) instead of `text`, so pictures cross the bridge both ways (`after_tool` already saw `output.content`). The dsh adapter turns dsh image blocks (references into dsh's attachment store) into yoagent images through the `attachments` service when one is loaded; the rutis-agent example reads a runner's `{"content": [...]}` value as blocks (rutis-agent results are otherwise text). Rust handlers already returned full yoagent tool results. Tests: `content_blocks` unit test, TypeScript/Python round trip in `languages_test`, dsh with and without a store in `dsh_test`.
- Test fix: `dsh_test` no longer reads the abort file mid-write (empty) as the result.

## 0.25.0 (2026-10-08)

### Added
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions integrations/yoagent-rutis/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ rutis = "0.6"
# rutis-bridge minor bump is a yoagent-rutis minor bump too.
rutis-bridge = { version = "0.7", optional = true, default-features = false }
async-trait = "0.1"
# Decodes plugin images to check them (yoagent itself uses 0.22).
base64 = "0.22"
futures = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Expand Down
32 changes: 30 additions & 2 deletions integrations/yoagent-rutis/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,27 @@ def apply(ctx, config):
return await res.text()
}
```
- **Images, both ways.** A tool result and an `after_tool` edit carry
`content` blocks in yoagent's JSON shape (pi's and MCP's too) instead of
`text` — `{"type": "text", "text"}` and
`{"type": "image", "data": <base64>, "mimeType": "image/…"}` — and
`after_tool` sees the output's blocks as `output.content`, so a handler
can return pictures, and keep, add or drop them when it edits a result.
Text and blocks are exclusive in one answer (so return picked fields,
not the `output` you were given). An image must be standard base64 of at
most 10 MB, typed `image/png`, `image/jpeg`, `image/gif` or `image/webp`
(what every provider takes); anything else in `content` fails the answer
— except, in an `after_tool` edit, an image identical to one in
`output.content`: keeping what yoagent let in (`read_file` takes bmp, up
to 20 MB) never fails the edit.
Stay under your provider's own limit too (Anthropic: 5 MB base64): an
image it refuses sits in the history and fails every later request.

```ts
async call_tool(call) {
return { content: [{ type: 'text', text: 'the chart' }, { type: 'image', data: png.toString('base64'), mimeType: 'image/png' }] }
}
```
- **The bridge never loads plugins**: the host does, typically with
[rutis-loader](https://crates.io/crates/rutis-loader) rows, and must share
`yoagent` in the loader's catalog (`catalog.register_shared("yoagent")` or
Expand Down Expand Up @@ -265,7 +286,7 @@ offers every tool in dsh's tool registry (the `tools` service of
| Hook | What the adapter does |
|---|---|
| `tools` | `tools.schemas()` → name, description, parameters (config `tools`: an allowlist) |
| `call_tool` | `tools.execute({callId, name, arguments, signal})` with the bridge's cancel handle as dsh's `signal`: cancelling the run aborts the dsh call. `isError` → an error tool result; text blocks joined |
| `call_tool` | `tools.execute({callId, name, arguments, signal})` with the bridge's cancel handle as dsh's `signal`: cancelling the run aborts the dsh call. `isError` → an error tool result. Text blocks stay text; an image block — a reference into dsh's attachment store — becomes a yoagent image, its bytes read with the `attachments` service when one is loaded (looked up per image, so the adapter also runs without it). Without one, when a read fails, when dsh marked the image `offloaded`, or over 3.75 MB (under Anthropic's 5 MB once base64), the image is a text placeholder; an error result's images are not read; other blocks are named |
| `before_model` | the system-prompt sections dsh plugins added (the harness identity and persona slots left out, sections whose variables are unset skipped), as one note, capped at `maxNoteChars` (2000) |

Load, as rows of one Node runtime whose `package.json` is `plugins/dsh/`'s:
Expand All @@ -286,7 +307,8 @@ cargo run --features node --example dsh_tools -- --live # DeepSeek: DEEPSEEK_AP
the adapter, runs one search, and checks that a dsh tool answered (and,
scripted, that free-search's prompt section reached the model).
`tests/dsh_test.rs` covers the adapter offline with a fixture dsh plugin
(`plugins/dsh/fixture-tools.ts`).
(`plugins/dsh/fixture-tools.ts`) and, for images, a stand-in attachment
store (`plugins/dsh/fixture-attachments.ts`).

### rutis-agent tools

Expand All @@ -297,6 +319,12 @@ Rust rutis plugin that maps every `ToolDef` to a yoagent tool per run
(results as text, failures as `ToolError`s, yoagent's cancel token passed as
rutis-agent's), shown with rutis-agent's `replace_text` and a tool registered
into the registry while the host runs, which the next run offers.
rutis-agent's results are text (a runner's JSON value is serialized), so
images use a convention of this adapter: a runner returning
`{"content": [blocks]}` in yoagent's block shape, with at least one image,
gives yoagent those blocks (`dot_picture` in the example); rutis-agent's
own agent still sees the JSON text. A tool that prints exactly such JSON
(an image included) as its text would be read as blocks too.

```sh
cargo run --manifest-path examples/rutis-agent-tools/Cargo.toml [-- --live]
Expand Down
73 changes: 69 additions & 4 deletions integrations/yoagent-rutis/examples/rutis-agent-tools/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
//! - its schema (name, description, parameters) is the tool's;
//! - a result is text, a failure (rutis-agent's `ok: false`, its
//! `error: ...` text) is a real `ToolError::Failed`;
//! - images: rutis-agent's results are text (a runner's JSON value is
//! serialized). By this adapter's convention a runner that returns
//! `{"content": [blocks]}` — yoagent's text and image blocks,
//! `{"type": "image", "data": <base64>, "mimeType": "image/…"}` — gives
//! yoagent those blocks, images included (rutis-agent's own agent still
//! sees the JSON text);
//! - yoagent's cancel token *is* the token rutis-agent's `execute` watches,
//! so cancelling the run stops the runner. The tool returns
//! `ToolError::Cancelled`, but the transcript shows yoagent's "Tool result
Expand All @@ -25,7 +31,9 @@
//! 2. while the host runs, a `word_count` tool is registered into
//! rutis-agent's `ToolRegistry` — it appears on the agent's next run, with
//! no change to the agent or the adapter (hot add);
//! 3. (scripted only) a slow tool, added the same way, is cancelled with
//! 3. (scripted only) a `dot_picture` tool, added the same way, returns a
//! picture: it reaches yoagent as an image;
//! 4. (scripted only) a slow tool, added the same way, is cancelled with
//! `Agent::abort()`: its runner never finishes.
//!
//! The model is scripted by default; `--live` asks DeepSeek instead
Expand Down Expand Up @@ -163,12 +171,34 @@ impl AgentTool for RegistryTool {
return Err(ToolError::Failed(out.output));
}
Ok(ToolResult {
content: vec![Content::Text { text: out.output }],
content: content_of(&out.output)
.unwrap_or_else(|| vec![Content::Text { text: out.output }]),
details: Value::Null,
})
}
}

/// A runner's `{"content": [blocks]}` value (as rutis-agent serialized it),
/// read back as yoagent content: only text and image blocks, only when the
/// whole value is that shape, and only with at least one image — so a tool
/// that prints such JSON as text (`cat result.json`) mostly stays text.
fn content_of(output: &str) -> Option<Vec<Content>> {
if !output.starts_with('{') {
return None;
}
let mut value: serde_json::Map<String, Value> = serde_json::from_str(output).ok()?;
if value.len() != 1 {
return None;
}
let blocks: Vec<Content> = serde_json::from_value(value.remove("content")?).ok()?;
let image = |b: &Content| matches!(b, Content::Image { data, mime_type } if !data.is_empty() && mime_type.starts_with("image/"));
let valid = blocks
.iter()
.all(|b| matches!(b, Content::Text { .. }) || image(b))
&& blocks.iter().any(image);
valid.then_some(blocks)
}

// ── The host ────────────────────────────────────────────────────

/// A tool registered into rutis-agent's registry while the host runs.
Expand All @@ -192,6 +222,26 @@ fn word_count_tool() -> ToolDef {
)
}

/// A 1×1 PNG, base64.
const DOT_PNG: &str =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==";

/// A tool returning a picture: text and an image, in the content-block
/// convention this adapter reads.
fn dot_picture_tool() -> ToolDef {
ToolDef::new(
"dot_picture",
"Shows a picture of a dot.",
json!({"type": "object", "properties": {}}),
|_| async move {
Ok(json!({"content": [
{"type": "text", "text": "a dot"},
{"type": "image", "data": DOT_PNG, "mimeType": "image/png"},
]}))
},
)
}

/// A tool that takes a minute unless cancelled; `finished` says whether its
/// runner ever completed.
fn slow_tool(finished: Arc<AtomicBool>) -> ToolDef {
Expand Down Expand Up @@ -348,7 +398,10 @@ async fn main() -> Result<(), BoxError> {
// Run 2: the tool added while the host ran.
call("word_count", json!({"path": path})),
MockResponse::Text("Counted.".into()),
// Run 3: cancelled mid-call.
// Run 3: a picture.
call("dot_picture", json!({})),
MockResponse::Text("Seen.".into()),
// Run 4: cancelled mid-call.
call("slow", json!({})),
MockResponse::Text("never sent".into()),
]);
Expand Down Expand Up @@ -415,7 +468,19 @@ async fn main() -> Result<(), BoxError> {
format!("word_count was not hot-added between the runs: {offered:?}"),
)?;

// 3. Cancel: yoagent's token stops rutis-agent's runner.
// 3. Images: a runner's content blocks reach yoagent as an image.
registry.register(dot_picture_tool());
let before = agent.messages().len();
run(&mut agent, "Show me the dot.").await?;
let image = agent.messages()[before..].iter().any(|m| {
matches!(m, AgentMessage::Llm(Message::ToolResult { tool_name, content, .. })
if tool_name == "dot_picture"
&& content.iter().any(|c| matches!(c, Content::Image { data, .. } if data == DOT_PNG)))
});
println!(" dot_picture returned an image: {image}");
check(image, "dot_picture's image did not reach yoagent")?;

// 4. Cancel: yoagent's token stops rutis-agent's runner.
let finished = Arc::new(AtomicBool::new(false));
registry.register(slow_tool(finished.clone()));
let mut events = agent.prompt("Take your time.").await;
Expand Down
83 changes: 76 additions & 7 deletions integrations/yoagent-rutis/plugins/dsh/dsh-tools-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,41 @@
// call_tool `tools.execute({callId, name, arguments, signal})`, with the
// bridge's cancel handle as dsh's `signal`: cancelling the
// yoagent run aborts the dsh call. `isError` → an error tool
// result; text blocks are joined, other blocks named.
// result. Text blocks stay text; an image block (a reference
// into dsh's attachment store) becomes a yoagent image, read
// with the `attachments` service when one is loaded (looked up
// per image, not injected: the adapter runs without it). With
// none, when a read fails, when dsh marked the image
// `offloaded`, or when it is over MAX_IMAGE_BYTES (provider-safe:
// Anthropic takes 5 MB base64), the image is a text
// placeholder. Other blocks are named. An error result's
// images are not read.
// before_model the sections dsh plugins added to `systemPrompt` (the
// harness identity and persona slots left out), rendered and
// capped, as a note on the request's latest user turn.

import { definePlugin } from '@arcships/rutis'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type { Cancellable, ToolCall, ToolResult, ToolSpec, Yoagent } from '../yoagent.d.ts'
import type { Cancellable, ContentBlock, ToolCall, ToolResult, ToolSpec, Yoagent } from '../yoagent.d.ts'

/** The slice of `@deepseek-ai/dsh-attachment`'s `AttachmentStore` this adapter uses. */
interface DshAttachments {
readImage(
ref: { attachmentId: string; mediaType: string },
signal?: AbortSignal,
): Promise<{ ref: { mediaType: string }; data: Uint8Array }>
}

/** A dsh content block as a tool result carries it. */
type DshBlock = {
type: string
text?: string
attachment?: { attachmentId: string; mediaType: string; name?: string; bytes?: number }
offloaded?: true
}

/** Largest image sent as an image: under Anthropic's 5 MB once base64-encoded. */
const MAX_IMAGE_BYTES = 3_750_000

/** The slice of `@deepseek-ai/dsh-tools`' `ToolRuntime` this adapter uses. */
interface DshTools {
Expand All @@ -39,7 +66,7 @@ interface DshTools {
signal: AbortSignal
}): Promise<{
isError: boolean
content?: { type: string; text?: string }[]
content?: DshBlock[]
error?: { message?: string; info?: { code?: string } }
}>
}
Expand Down Expand Up @@ -87,8 +114,40 @@ export default definePlugin<Config>({
const prompt = ctx.use<DshSystemPrompt>('systemPrompt')
const yoagent = ctx.use<Yoagent>('yoagent')
const only = config?.tools ? new Set(config.tools) : undefined
/** dsh's attachment store, when one is loaded: optional, so looked up per use rather than injected. */
const attachments = (): DshAttachments | undefined => {
try {
return ctx.use<DshAttachments>('attachments')
} catch {
return undefined
}
}
const maxNote = config?.maxNoteChars ?? 2000

/** An image reference as a yoagent image: its bytes from dsh's attachment store. */
const image = async (
ref: { attachmentId: string; mediaType: string; name?: string; bytes?: number },
offloaded: boolean,
signal: AbortSignal,
): Promise<ContentBlock> => {
const named = `image${ref.name ? ` ${ref.name}` : ''}`
const label = `[${named}: not available here]`
// dsh decided this image goes out as text; and an oversize one would fail the request.
if (offloaded) return { type: 'text', text: `[${named}: offloaded]` }
if ((ref.bytes ?? 0) > MAX_IMAGE_BYTES) return { type: 'text', text: `[${named}: too large to send]` }
const store = attachments()
if (!store) return { type: 'text', text: label }
try {
const stored = await store.readImage(ref, signal)
if (stored.data.byteLength > MAX_IMAGE_BYTES) return { type: 'text', text: `[${named}: too large to send]` }
return { type: 'image', data: Buffer.from(stored.data).toString('base64'), mimeType: stored.ref.mediaType }
} catch (error) {
if (signal.aborted) throw error
console.warn(`[dsh] image ${ref.attachmentId} could not be read: ${error}`)
return { type: 'text', text: label }
}
}

ctx.effect(
yoagent.register(config?.name ?? 'dsh-tools', {
async tools(): Promise<ToolSpec[]> {
Expand All @@ -109,13 +168,23 @@ export default definePlugin<Config>({
arguments: call.args,
signal: call.signal,
})
const text = (out.content ?? [])
.map((block) => (block.type === 'text' ? (block.text ?? '') : `[${block.type} block]`))
.join('\n')
if (out.isError) {
const text = (out.content ?? [])
.map((block) => (block.type === 'text' ? (block.text ?? '') : `[${block.type} block]`))
.join('\n')
return { text: text || out.error?.message || 'the dsh tool failed', is_error: true }
}
return { text }
const content: ContentBlock[] = []
for (const block of out.content ?? []) {
if (block.type === 'text') {
content.push({ type: 'text', text: block.text ?? '' })
} else if (block.type === 'image' && block.attachment) {
content.push(await image(block.attachment, block.offloaded === true, call.signal))
} else {
content.push({ type: 'text', text: `[${block.type} block]` })
}
}
return { content }
},

async before_model(turn: Cancellable) {
Expand Down
22 changes: 22 additions & 0 deletions integrations/yoagent-rutis/plugins/dsh/fixture-attachments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// A test fixture: a stand-in for dsh's `attachments` service
// (`@deepseek-ai/dsh-attachment`'s `AttachmentStore`), holding one image —
// a 1×1 PNG under the id `fixture-dot`. Only `readImage` is implemented.
// Not for production use.

import { definePlugin } from '@arcships/rutis'

const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==',
'base64',
)

export default definePlugin({
apply(ctx) {
ctx.provide('attachments', {
async readImage(ref: { attachmentId: string }) {
if (ref.attachmentId !== 'fixture-dot') throw new Error(`no attachment ${ref.attachmentId}`)
return { ref, data: new Uint8Array(PNG) }
},
})
},
})
32 changes: 32 additions & 0 deletions integrations/yoagent-rutis/plugins/dsh/fixture-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
//
// fixture_echo {text} → "echo: <text>"
// fixture_fail {why} → throws: dsh reports an `isError` result
// fixture_dot {} → a text block, an image block referring to the
// attachment `fixture-dot` (see fixture-attachments.ts),
// an offloaded one and an oversize one
// fixture_slow {} → writes "started" to `config.abortFile`, waits for
// `exec.signal` to abort (60 s at most), then
// writes how it ended there
Expand Down Expand Up @@ -49,6 +52,35 @@ export function apply(ctx: any, config: Config | undefined) {
throw new Error(`fixture failure: ${args.why}`)
},
}),
defineTool({
name: 'fixture_dot',
description: 'Shows a picture of a dot.',
parameters: {},
output: {
schema: TEXT,
render: () => [
{ type: 'text' as const, text: 'a dot' },
{
type: 'image' as const,
attachment: { attachmentId: 'fixture-dot', mediaType: 'image/png', bytes: 70, width: 1, height: 1 },
},
// dsh decided to send this one as text.
{
type: 'image' as const,
attachment: { attachmentId: 'fixture-dot', mediaType: 'image/png', bytes: 70, width: 1, height: 1, name: 'offloaded.png' },
offloaded: true as const,
},
// Over the adapter's provider-safe limit.
{
type: 'image' as const,
attachment: { attachmentId: 'fixture-huge', mediaType: 'image/png', bytes: 9_000_000, width: 9000, height: 9000, name: 'huge.png' },
},
],
},
async execute() {
return 'a dot'
},
}),
defineTool({
name: 'fixture_slow',
description: 'Waits until it is cancelled.',
Expand Down
Loading
Loading