Skip to content
Open
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
8 changes: 7 additions & 1 deletion docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -678,7 +678,13 @@ Effective model and effort precedence is: per-profile
`effort` on `task`/`fleet`, profile frontmatter, `agent.subagent_model` /
`agent.subagent_effort`, then executor/default model configuration.

`task` accepts optional `profile` and `write_paths`. `fleet` dispatches 2–64
`task` accepts optional `profile`, `write_paths`, and `images`. `images` passes
workspace-local image files (≤8, deduplicated, order preserved) created earlier
in the turn — rendered charts, screenshots, extracted video frames — to the
sub-agent as image input; each path is resolved through the same
workspace-confinement, symlink, size, and TOCTOU checks as `@`-references, and a
failing path fails the call. The child's provider still owns the vision
decision: text-only children receive metadata only. `fleet` dispatches 2–64
profile-aware tasks under a session scheduler
(`agent.max_subagent_concurrency`, default 6; `agent.max_parallel_writers`,
default 3). Profile names are resolved at runtime from the Skill store and
Expand Down
2 changes: 1 addition & 1 deletion docs/SPEC.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ Bubble Tea TUI 的 modal overlay 必须隐藏 composer;slash/`@` autocomplete

子智能体 Profile 是带 `runAs: subagent` 的 Skill。桌面端和 CLI 只允许修改简单、手动调用的 project/global profile;包含 `references/`、`scripts/` 或非托管 frontmatter 的丰富 Skill 不会被编辑器扁平化覆盖。

`reasonix subagent try` 使用只读 Skill runner;`reasonix subagent run` 使用常规权限与 Sandbox。`task` 支持 `profile`、`model`、`effort` 和 `write_paths`;`fleet` 在 session scheduler 上并发调度多个任务。详见[子智能体 Profile](./SUBAGENT_PROFILES.zh-CN.md)。
`reasonix subagent try` 使用只读 Skill runner;`reasonix subagent run` 使用常规权限与 Sandbox。`task` 支持 `profile`、`model`、`effort`、`write_paths` 和 `images`;`images` 把本轮早先生成的 workspace 内图片文件(≤8 个,去重、保序)作为图像输入传给子智能体——每条路径都经过与 `@` 引用相同的 workspace 限制、symlink、大小与 TOCTOU 检查,解析失败则整次调用失败。是否真正嵌入像素仍由子智能体的 provider 决定:纯文本子智能体只收到元数据。`fleet` 在 session scheduler 上并发调度多个任务。详见[子智能体 Profile](./SUBAGENT_PROFILES.zh-CN.md)。

Profile 描述的是 worker,不是一次运行。委派由五个彼此独立的概念构成:profile 说明这个 worker 怎么思考,`TaskSpec` 说明本次要什么,`CapabilityGrant` 说明本次能碰什么,`ContextCapsule` 说明从什么上下文起步,`SchedulerPolicy` 说明何时以及怎么运行。字段归属于**决定其取值**的那一方,因此 profile 可以携带能力**上界**(`allowed-tools`、`read-only`),但绝不能携带 `max_turns`、`write_paths`、重试或验证策略这类按次取值——它们由任务或调度决定。Skill frontmatter 可以继续变胖;`agent.ProfileFromSkill` 是唯一的收窄点,路由元数据(triggers、auto-use、cost、freshness)到此为止,因为它决定的是**何时**选中一个 worker,而不是它怎么思考。`internal/agent/profile_boundary_test.go` 会在任何一次拓宽时失败。

Expand Down
5 changes: 5 additions & 0 deletions docs/SUBAGENT_PROFILES.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ fleet(tasks=[
(explicit names may call `invocation: manual` profiles).
- The profile body becomes the **full** child system prompt — no implicit
concise default is stacked on top.
- `task` and `read_only_task` also accept `images`: workspace-local image file
paths produced earlier in the turn (rendered charts, screenshots, video
frames) that a vision-capable sub-agent should inspect as pixels. Paths
resolve through the same workspace-confinement checks as `@`-references;
text-only children keep metadata only.
- `write_paths` declares write targets so parallel writers can share one
workspace. File claims must be disjoint to start together. Directory claims
may start together and only serialize when they realize the same file.
Expand Down
3 changes: 3 additions & 0 deletions docs/SUBAGENT_PROFILES.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ fleet(tasks=[
- `task` / `fleet` 项上的 `profile` 按名称解析 `runAs: subagent` Skill(显式名称可调用
`invocation: manual` Profile)。
- Profile 正文成为子智能体的**完整**系统提示词,不再隐式叠加 concise 默认提示。
- `task` 与 `read_only_task` 还接受 `images`:把本轮早先生成的 workspace 内图片文件
(渲染的图表、截图、视频帧)传给具备视觉能力的子智能体直接查看像素。路径经过与
`@` 引用相同的 workspace 限制检查;纯文本子智能体仅收到元数据。
- `write_paths` 声明写入目标,使多个写入子智能体可共享同一工作区并行。文件声明
必须互不重叠才能同时开工。目录声明可以同时开工,只有落盘到同一文件时才互斥。
写入任务若省略 `write_paths`,开工时声明整个工作区。若之后只有路径型写入,
Expand Down
2 changes: 1 addition & 1 deletion internal/agent/profile_boundary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func TestDelegationSpecMembersStaySeparate(t *testing.T) {
"ReadOnly", "AllowNoTools", "CallTools", "ProfileTools", "WritePaths",
})
assertFieldSet(t, "ContextRequest", ContextRequest{}, []string{
"ContinueFrom", "ForkFrom", "Ephemeral", "Decisions", "EvidenceSummary", "FileAnchors", "OutputFormat",
"ContinueFrom", "ForkFrom", "Ephemeral", "Images", "Decisions", "EvidenceSummary", "FileAnchors", "OutputFormat",
})
assertFieldSet(t, "SchedulerPolicy", SchedulerPolicy{}, []string{
"MaxSteps", "MaxOutputTokens", "RunInBackground", "BackgroundWriter", "Nested",
Expand Down
3 changes: 3 additions & 0 deletions internal/agent/profile_spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ type ContextRequest struct {
// Ephemeral forces a non-persisted transcript for entry points that promise
// no durable host side effects, such as read_only_task.
Ephemeral bool
// Images are the parent-resolved data URLs this call explicitly attaches.
// They merge with turn candidates at dispatch, never replacing them.
Images []string
// Decisions, EvidenceSummary, FileAnchors, and OutputFormat are the only
// parent facts a child should start from. The parent transcript is not copied.
Decisions []acceptedDecision
Expand Down
103 changes: 94 additions & 9 deletions internal/agent/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,10 @@ type TaskTool struct {
// sub-agent gets its own use_capability frontend so ledger state stays
// isolated while connections reuse the parent Host.
capabilityRuntime *MCPCapabilityRuntime
completion taskCompletionConfig
// imageResolver turns a task call's image parameter paths into data URLs
// under the same workspace security matrix as @-references.
imageResolver func(path, baseDir string) (string, error)
completion taskCompletionConfig
}

// TaskToolOptions holds the construction parameters for a TaskTool.
Expand Down Expand Up @@ -429,6 +432,14 @@ func (t *TaskTool) WithScheduler(s *SubagentScheduler) *TaskTool {
return t
}

// WithImageResolver overrides how task/read_only_task image parameters become
// provider-visible data URLs. Production wires fileref.FileImageDataURL with
// the workspace root; tests inject fakes to observe resolution behavior.
func (t *TaskTool) WithImageResolver(fn func(path, baseDir string) (string, error)) *TaskTool {
t.imageResolver = fn
return t
}

// Scheduler returns the attached session scheduler (may be nil in unit tests).
func (t *TaskTool) Scheduler() *SubagentScheduler {
if t == nil {
Expand Down Expand Up @@ -487,7 +498,8 @@ func (t *TaskTool) Schema() json.RawMessage {
"run_in_background":{"type":"boolean","description":"Run the sub-agent asynchronously: returns a job id immediately and keeps working across turns. Collect its final answer with wait, and you'll be notified when it finishes. Use for long, independent sub-tasks you don't need to block on right now."},
"model":{"type":"string","description":"Optional model override for the sub-agent (a configured provider/model name). Precedence: persistent profile config, this argument, profile frontmatter, global subagent default, parent model."},
"effort":{"type":"string","description":"Optional reasoning effort for the sub-agent (e.g. high, max). Same precedence as model."},
"continue_from":{"type":"string","description":"Continue a prior compatible subagent transcript in the current conversation context. Pass only the 'sa_...' value from the prior result's 'Subagent reference: ...' line. If the ref belongs to an ancestor conversation, the framework continues a current-conversation copy."}
"continue_from":{"type":"string","description":"Continue a prior compatible subagent transcript in the current conversation context. Pass only the 'sa_...' value from the prior result's 'Subagent reference: ...' line. If the ref belongs to an ancestor conversation, the framework continues a current-conversation copy."},
"images":{"type":"array","items":{"type":"string"},"description":"Optional image file paths (workspace-relative or absolute, must be inside the workspace) to pass to the sub-agent as image input for a vision-capable model. Use when the image was produced during this task (rendered chart, screenshot, extracted video frame) and the sub-agent must see the pixels, not just the path. Non-image or unreadable files fail the call. Max 8; deduplicated, order preserved."}
},
"required":["prompt"]
}`)
Expand Down Expand Up @@ -561,7 +573,8 @@ func (*ReadOnlyTaskTool) Schema() json.RawMessage {
"tools":{"type":"array","items":{"type":"string"},"description":"Optional read-only tool whitelist. Writer, installer, memory mutation, background job, and delegation tools are never exposed."},
"max_steps":{"type":"integer","description":"Optional cap on tool-call rounds. Defaults to half the parent's cap (min 5).","minimum":1},
"model":{"type":"string","description":"Optional model override for the sub-agent (a configured provider/model name)."},
"effort":{"type":"string","description":"Optional reasoning effort for the sub-agent (e.g. high, max)."}
"effort":{"type":"string","description":"Optional reasoning effort for the sub-agent (e.g. high, max)."},
"images":{"type":"array","items":{"type":"string"},"description":"Optional image file paths (workspace-relative or absolute, must be inside the workspace) to pass to the sub-agent as image input for a vision-capable model. Non-image or unreadable files fail the call. Max 8; deduplicated, order preserved."}
},
"required":["prompt"]
}`)
Expand Down Expand Up @@ -592,10 +605,15 @@ func (r *ReadOnlyTaskTool) Execute(ctx context.Context, args json.RawMessage) (s
MaxSteps int `json:"max_steps"`
Model string `json:"model"`
Effort string `json:"effort"`
Images []string `json:"images"`
}
if err := json.Unmarshal(args, &p); err != nil {
return "", fmt.Errorf("invalid args: %w", err)
}
images, err := r.task.resolveTaskImages(p.Images)
if err != nil {
return "", err
}
// Every entry point compiles to a spec and runs through RunProfileSpec, so a
// boundary added there cannot be missed by one caller. read_only_task keeps
// its own promise of no durable side effects through Ephemeral.
Expand All @@ -605,6 +623,7 @@ func (r *ReadOnlyTaskTool) Execute(ctx context.Context, args json.RawMessage) (s
}
spec.Worker.SystemPrompt = DefaultReadOnlyTaskSystemPrompt
spec.Context.Ephemeral = true
spec.Context.Images = images
return r.task.RunProfileSpec(ctx, spec)
}

Expand Down Expand Up @@ -633,18 +652,24 @@ func (t *TaskTool) Execute(ctx context.Context, args json.RawMessage) (string, e
Effort string `json:"effort"`
ContinueFrom string `json:"continue_from"`
ForkFrom string `json:"fork_from"`
Images []string `json:"images"`
}
if err := json.Unmarshal(args, &p); err != nil {
return "", fmt.Errorf("invalid args: %w", err)
}
if strings.TrimSpace(p.Prompt) == "" {
return "", fmt.Errorf("prompt is required")
}
images, err := t.resolveTaskImages(p.Images)
if err != nil {
return "", err
}

spec, err := t.buildTaskSpec(ctx, p.Prompt, p.Description, p.Profile, p.WritePaths, p.Tools, p.MaxSteps, p.Model, p.Effort, p.ContinueFrom, p.ForkFrom, p.RunInBackground, false)
if err != nil {
return "", err
}
spec.Context.Images = images
return t.RunProfileSpec(ctx, spec)
}

Expand Down Expand Up @@ -728,6 +753,66 @@ func (t *TaskTool) resolveWriterClaims(writePaths []string, requireClaim bool) (
return WholeWorkspaceWriteClaim(t.workspaceRoot)
}

// maxTaskImages caps the explicit per-call image parameter so one dispatch
// cannot balloon the child's provider request.
const maxTaskImages = 8

// mergeSubagentImages combines the call's explicit images with the parent's
// turn candidates: param first, then candidates, deduplicated, order preserved.
func mergeSubagentImages(param, candidates []string) []string {
if len(param) == 0 {
return candidates
}
if len(candidates) == 0 {
return param
}
seen := make(map[string]bool, len(param)+len(candidates))
out := make([]string, 0, len(param)+len(candidates))
for _, url := range append(append([]string(nil), param...), candidates...) {
if url == "" || seen[url] {
continue
}
seen[url] = true
out = append(out, url)
}
return out
}

// resolveTaskImages converts the call's image paths into provider-visible data
// URLs. Unlike the parent turn path (best-effort skip), a param-passed path
// that fails validation fails the call: the model explicitly asked for these
// pixels and silently dropping them would produce confident wrong answers.
func (t *TaskTool) resolveTaskImages(paths []string) ([]string, error) {
if len(paths) == 0 {
return nil, nil
}
if len(paths) > maxTaskImages {
return nil, fmt.Errorf("images accepts at most %d paths, got %d", maxTaskImages, len(paths))
}
resolve := t.imageResolver
if resolve == nil {
return nil, nil
}
urls := make([]string, 0, len(paths))
seen := map[string]bool{}
for _, path := range paths {
path = strings.TrimSpace(path)
if path == "" {
return nil, fmt.Errorf("images entries must be non-empty paths")
}
url, err := resolve(path, t.workspaceRoot)
if err != nil {
return nil, fmt.Errorf("image %q: %w", path, err)
}
if url == "" || seen[url] {
continue
}
seen[url] = true
urls = append(urls, url)
}
return urls, nil
}

// RunProfileSpec executes a unified profile/task specification. Shared by task,
// fleet items, and boot-wired skill runners so prompt, tools, claims, and
// scheduling cannot drift across entry points.
Expand Down Expand Up @@ -828,9 +913,9 @@ func (t *TaskTool) RunProfileSpec(ctx context.Context, spec ProfileExecSpec) (re
defer mutationObserver.UnregisterWriter(recoveryTaskID)
}
if spec.Grant.ReadOnly {
return t.runReadOnlySubSession(runCtx, composeChildTaskPrompt(spec), subReg, sink, maxSteps, prov, pricing, ctxWin, run.Session, childDepth, recoveryTaskID, usageModelRef, mutationObserver)
return t.runReadOnlySubSession(runCtx, spec, composeChildTaskPrompt(spec), subReg, sink, maxSteps, prov, pricing, ctxWin, run.Session, childDepth, recoveryTaskID, usageModelRef, mutationObserver)
}
return t.runSubSession(WithSubagentWriteClaim(runCtx, spec.Grant.WritePaths), composeChildTaskPrompt(spec), subReg, sink, maxSteps, prov, pricing, ctxWin, run.Session, childDepth, recoveryTaskID, usageModelRef, mutationObserver, childWriteRoots)
return t.runSubSession(WithSubagentWriteClaim(runCtx, spec.Grant.WritePaths), spec, composeChildTaskPrompt(spec), subReg, sink, maxSteps, prov, pricing, ctxWin, run.Session, childDepth, recoveryTaskID, usageModelRef, mutationObserver, childWriteRoots)
}

if spec.Sched.RunInBackground {
Expand Down Expand Up @@ -1540,7 +1625,7 @@ func (t *TaskTool) resolveSubSessionRuntime(modelRef, effort string) (provider.P
return prov, pricing, ctxWin, nil
}

func (t *TaskTool) runSubSession(ctx context.Context, prompt string, subReg *tool.Registry, sink event.Sink, maxSteps int, prov provider.Provider, pricing *provider.Pricing, ctxWin int, sess *Session, childDepth int, recoveryTaskID, modelRef string, mutationObserver *checkpoint.MutationObserver, writeRoots *sandbox.WritableRootSet) (string, error) {
func (t *TaskTool) runSubSession(ctx context.Context, spec ProfileExecSpec, prompt string, subReg *tool.Registry, sink event.Sink, maxSteps int, prov provider.Provider, pricing *provider.Pricing, ctxWin int, sess *Session, childDepth int, recoveryTaskID, modelRef string, mutationObserver *checkpoint.MutationObserver, writeRoots *sandbox.WritableRootSet) (string, error) {
opts := t.subagentOptions(ctx, maxSteps, pricing, ctxWin, childDepth, recoveryTaskID, mutationObserver)
if writeRoots != nil {
opts.WriteRoots = writeRoots
Expand All @@ -1552,18 +1637,18 @@ func (t *TaskTool) runSubSession(ctx context.Context, prompt string, subReg *too
prompt = t.withWorkspaceContext(prompt) + "\n\n" + completeSubtaskContract
// The child provider owns the final vision decision. Text-only providers
// retain the attachment metadata but omit image parts during serialization.
ctx = WithUserImages(ctx, SubagentImageCandidates(ctx))
ctx = WithUserImages(ctx, mergeSubagentImages(spec.Context.Images, SubagentImageCandidates(ctx)))
return RunSubAgentWithSession(ctx, prov, subReg, sess, prompt, opts, sink)
}

func (t *TaskTool) runReadOnlySubSession(ctx context.Context, prompt string, subReg *tool.Registry, sink event.Sink, maxSteps int, prov provider.Provider, pricing *provider.Pricing, ctxWin int, sess *Session, childDepth int, recoveryTaskID, modelRef string, mutationObserver *checkpoint.MutationObserver) (string, error) {
func (t *TaskTool) runReadOnlySubSession(ctx context.Context, spec ProfileExecSpec, prompt string, subReg *tool.Registry, sink event.Sink, maxSteps int, prov provider.Provider, pricing *provider.Pricing, ctxWin int, sess *Session, childDepth int, recoveryTaskID, modelRef string, mutationObserver *checkpoint.MutationObserver) (string, error) {
opts := t.subagentOptions(ctx, maxSteps, pricing, ctxWin, childDepth, recoveryTaskID, mutationObserver)
opts.ModelRef = modelRef
// Capture the pristine task before host framing is prepended: delivery
// intent classification must judge the task, not the wrapper.
opts.ClassifierTaskText = prompt
prompt = t.withWorkspaceContext(prompt)
ctx = WithUserImages(ctx, SubagentImageCandidates(ctx))
ctx = WithUserImages(ctx, mergeSubagentImages(spec.Context.Images, SubagentImageCandidates(ctx)))
return RunReadOnlySubAgentWithSession(ctx, prov, subReg, sess, prompt, opts, sink)
}

Expand Down
Loading