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
27 changes: 27 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,24 @@ const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds
const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors
const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors

/**
* In-memory phase marker for an auto-flattened inline subtask.
*
* When `new_task` would exceed `maxNestingDepth` and `autoFlattenOnLimit` is set,
* the subtask is NOT opened as a child Task. Instead the parent Task records this
* marker and executes the instruction inline in its own conversation (the tool_result
* doubles as the inline prompt). The marker is cleared when the inline phase completes
* (`attempt_completion`) or the task is aborted/cancelled.
*
* Deliberately NOT persisted: inline state is a transient execution phase, not lineage.
*/
export interface InlineSubtask {
/** The subtask instruction to execute inline. */
message: string
/** Parsed todos for the subtask (empty when none were provided). */
todos: TodoItem[]
}

export interface TaskOptions extends CreateTaskOptions {
provider: ClineProvider
apiConfiguration: ProviderSettings
Expand Down Expand Up @@ -178,6 +196,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
* resumed without their live parent — the provider backfills those before first save.
*/
readonly depthAuthoritative: boolean
/**
* Set while an auto-flattened subtask is executing inline in this task's own
* conversation. Cleared on completion or abort. Never persisted.
*/
inlineSubtask?: InlineSubtask
pendingNewTaskToolCallId?: string

readonly instanceId: string
Expand Down Expand Up @@ -2276,6 +2299,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {

this.abort = true

// Clear any in-flight inline subtask phase so a cancelled task resumes as an
// ordinary parent conversation with no orphaned marker.
this.inlineSubtask = undefined

// Reset consecutive error counters on abort (manual intervention)
this.consecutiveNoToolUseCount = 0
this.consecutiveNoAssistantMessagesCount = 0
Expand Down
10 changes: 10 additions & 0 deletions src/core/tools/AttemptCompletionTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {

task.consecutiveMistakeCount = 0

// Inline subtask phase completion (auto-flattened): clear the marker and let the
// loop continue with a tool_result. No askFinishSubTaskApproval — the user is
// already watching this same conversation, so an approval popup would be double
// friction. This task itself continues as before; it has NOT completed.
if (task.inlineSubtask) {
task.inlineSubtask = undefined
pushToolResult(`[inline subtask completed]\n${result}\nThe parent conversation continues.`)
return
}

await task.say("completion_result", result, undefined, false)

// Whether this attempt_completion call is a stale replay of an already-completed
Expand Down
35 changes: 35 additions & 0 deletions src/core/tools/NewTaskTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import * as vscode from "vscode"

import { TodoItem } from "@roo-code/types"

import { DEFAULT_AUTO_FLATTEN_ON_LIMIT, DEFAULT_MAX_NESTING_DEPTH } from "@roo-code/types"

import { Task } from "../task/Task"
import { decideInlineFlatten } from "./inlineSubtask"
import { getModeBySlug } from "../../shared/modes"
import { formatResponse } from "../prompts/responses"
import { t } from "../../i18n"
Expand Down Expand Up @@ -96,6 +99,38 @@ export class NewTaskTool extends BaseTool<"new_task"> {
return
}

// Auto-flatten inline decision (depth check BEFORE any approval prompt).
// When the subtask would exceed maxNestingDepth and autoFlattenOnLimit is set,
// it runs inline in this task's own conversation instead of opening a child tab.
const maxNestingDepth = state?.maxNestingDepth ?? DEFAULT_MAX_NESTING_DEPTH
const autoFlattenOnLimit = state?.autoFlattenOnLimit ?? DEFAULT_AUTO_FLATTEN_ON_LIMIT
const decision = decideInlineFlatten({
childDepth: task.depth + 1,
maxNestingDepth,
autoFlattenOnLimit,
inlineActive: task.inlineSubtask !== undefined,
message: unescapedMessage,
todos: todoItems,
})

if (decision.action === "reject-nested") {
pushToolResult(formatResponse.toolError(decision.message))
return
}

if (decision.action === "flatten") {
// Set the phase marker and let the tool_result double as the inline prompt.
task.inlineSubtask = { message: unescapedMessage, todos: todoItems }
pushToolResult(decision.directive)
return
}

if (decision.action === "reject-limit") {
pushToolResult(formatResponse.toolError(decision.message))
return
}

// decision.action === "delegate" — normal flow unchanged.
const toolMessage = JSON.stringify({
tool: "newTask",
mode: targetMode.name,
Expand Down
146 changes: 146 additions & 0 deletions src/core/tools/__tests__/inlineSubtask.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// npx vitest run core/tools/__tests__/inlineSubtask.spec.ts

import { describe, it, expect } from "vitest"
import type { TodoItem } from "@roo-code/types"
import { decideInlineFlatten, buildInlineDirective } from "../inlineSubtask"

const todos: TodoItem[] = [
{ content: "step one", status: "pending" },
{ content: "step two", status: "completed" },
] as unknown as TodoItem[]

describe("decideInlineFlatten (pure decision)", () => {
it("delegates normally when within the limit", () => {
const d = decideInlineFlatten({
childDepth: 2,
maxNestingDepth: 2,
autoFlattenOnLimit: true,
inlineActive: false,
message: "m",
todos,
})
expect(d).toEqual({ action: "delegate" })
})

it("delegates when exactly at the limit (childDepth === max)", () => {
const d = decideInlineFlatten({
childDepth: 2,
maxNestingDepth: 2,
autoFlattenOnLimit: true,
inlineActive: false,
message: "m",
todos,
})
expect(d.action).toBe("delegate")
})

it("flattens when over the limit and autoFlattenOnLimit is true", () => {
const d = decideInlineFlatten({
childDepth: 3,
maxNestingDepth: 2,
autoFlattenOnLimit: true,
inlineActive: false,
message: "do X",
todos,
})
expect(d.action).toBe("flatten")
if (d.action === "flatten") {
expect(d.directive).toContain("auto-flattened")
expect(d.directive).toContain("nesting limit 2 reached")
expect(d.directive).toContain("do X")
}
})

it("rejects when over the limit and autoFlattenOnLimit is false", () => {
const d = decideInlineFlatten({
childDepth: 3,
maxNestingDepth: 2,
autoFlattenOnLimit: false,
inlineActive: false,
message: "m",
todos,
})
expect(d.action).toBe("reject-limit")
if (d.action === "reject-limit") {
expect(d.message).toContain("auto-flatten is disabled")
}
})

it("treats maxNestingDepth 0 as delegation-disabled → always flatten when over", () => {
const d = decideInlineFlatten({
childDepth: 1,
maxNestingDepth: 0,
autoFlattenOnLimit: true,
inlineActive: false,
message: "m",
todos,
})
expect(d.action).toBe("flatten")
})

it("rejects a nested new_task while an inline phase is active (precedence over delegate)", () => {
const d = decideInlineFlatten({
childDepth: 1,
maxNestingDepth: 5,
autoFlattenOnLimit: true,
inlineActive: true,
message: "m",
todos,
})
expect(d.action).toBe("reject-nested")
})

it("rejects a nested new_task while active even when over the limit (nested wins)", () => {
const d = decideInlineFlatten({
childDepth: 9,
maxNestingDepth: 2,
autoFlattenOnLimit: true,
inlineActive: true,
message: "m",
todos,
})
expect(d.action).toBe("reject-nested")
})

it("includes todos in the flatten directive when present", () => {
const d = decideInlineFlatten({
childDepth: 3,
maxNestingDepth: 2,
autoFlattenOnLimit: true,
inlineActive: false,
message: "m",
todos,
})
if (d.action === "flatten") {
expect(d.directive).toContain("step one")
expect(d.directive).toContain("step two")
}
})

it("omits the Todos section when no todos are provided", () => {
const d = decideInlineFlatten({
childDepth: 3,
maxNestingDepth: 2,
autoFlattenOnLimit: true,
inlineActive: false,
message: "m",
todos: [],
})
if (d.action === "flatten") {
expect(d.directive).not.toContain("Todos:")
}
})
})

describe("buildInlineDirective", () => {
it("embeds the instruction and todos", () => {
const dir = buildInlineDirective("fix the bug", todos, 2)
expect(dir).toContain("fix the bug")
expect(dir).toContain("step one")
})

it("instructs to call attempt_completion when done", () => {
const dir = buildInlineDirective("m", [], 3)
expect(dir).toContain("attempt_completion")
})
})
Loading
Loading