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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,9 @@ most easily broken:
the `AgentCall` modes' `runWithSession` — so ephemeral continuation is only
reachable through a `Chat` handle).
Agent/conversation safety comes from bundling, not from types a flow
script combines: `Chat`, `FlowSession` and `Sessioned` carry their agent and
script combines: `Chat`, `FlowSession` and `WithChat` carry their agent and
conversation from creation, and every way to pair them (`Chat`'s
constructor, `Agent.chat(continueFrom)`, `Sessioned`'s `apply`/`copy`,
constructor, `Agent.chat(continueFrom)`, `WithChat`'s `apply`/`copy`,
`SessionId`, `WireSessionId`) is `private[orca]` or narrower.
`Chat.withAgent` swaps in a variant of the chat's agent and refuses one on
another backend instance. An adopted chat refuses a turn
Expand Down
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -712,7 +712,7 @@ Every way to talk to an agent, by what the conversation must do:
| `agent.chat()` → `chat.run(prompt)` / `chat.resultAs[O].{autonomous,interactive}.run(input)` | new, then continued by every turn | no | both | text or `O` | `InStage` | yes* |
| `agent.session(name, seed)` → `session.run(prompt)` / `session.resultAs[O].run(input)` | named; continued, or re-seeded if lost | yes | autonomous | text or `O` | `FlowContext`, `FlowControl`, `InStage`, `WorkspaceWrite` | no |
| `session.chat` → as `Chat` | the session's; refused while the backend doesn't hold it (never run, or lost on resume) | no (turns not recorded) | both | text or `O` | `InStage` | yes* |
| `Plan.{autonomous,interactive}.*` → `Sessioned`; `.reviewed()`, `.chat` | new planning conversation, continued by `.reviewed()` and `.chat` | no | as named | `O` | `FlowContext`, `InStage` | yes* |
| `Plan.{autonomous,interactive}.*` → `WithChat`; `.reviewed()`, `.chat` | new planning conversation, continued by `.reviewed()` and `.chat` | no | as named | `O` | `FlowContext`, `InStage` | yes* |
| `reviewAndFixLoop` / `reviewThenFix` | new reviewer chats; continues `coderSession` | the coder session does | autonomous | findings | `FlowContext`, `FlowControl`, `InStage`, `WorkspaceWrite` | no |
| `lint(commands, agent)` | new (or continues a `Lint.summariser`) | no | autonomous | `ReviewResult` (`LintReport` with a summariser) | `FlowContext`, `InStage` | yes |

Expand Down Expand Up @@ -905,15 +905,15 @@ splits `autonomous` / `interactive`:
| `assessThenPlan(userPrompt, agent, instructions?)` | `Verdict[Plan]` | assess, then `Proceed(plan)` or `Rejection(kind, body)` | same, but can ask the reporter to clarify instead of rejecting |
| `triage(report, agent, instructions?)` | `Triage` | classify a bug report (not-a-bug / untestable / testable) | same, with clarifying questions |

Every cell returns `Sessioned[<result>]` — the result paired with the
Every cell returns `WithChat[<result>]` — the result paired with the
(ephemeral) `Chat` that produced it. Continue that conversation in-run
(`chat.run(task)`; continuations have write access), or `.value` it and start a
fresh, durable implementer session via `agent.session("implementer", seed =
plan.brief)` — the chat does not survive a crash/resume, so every
shipped example takes `.value`. Destructure when you want both: `val
Sessioned(chat, plan) = Plan.autonomous.from(...)`.
WithChat(chat, plan) = Plan.autonomous.from(...)`.

From a `Sessioned[Plan]`, an optional `.reviewed()` step refines the plan
From a `WithChat[Plan]`, an optional `.reviewed()` step refines the plan
before implementing — the planner critiques its own draft, read-only, producing
an improved `Plan`. Chain it: `Plan.autonomous.from(...).reviewed().value`.
`.reviewed(variant = _.cheap)` runs the review on a variant of the read-only
Expand Down Expand Up @@ -1110,7 +1110,7 @@ layer — replace the whole set via `flow(prompts = ...)`. See [ADR

Common types you'll see in flow scripts. Most `derives JsonData`, making them
valid stage results (the progress log can record and replay them) and usable as
structured LLM output via `claude.resultAs[T]`. Exceptions: `Sessioned` and
structured LLM output via `claude.resultAs[T]`. Exceptions: `WithChat` and
`Verdict` do not derive `JsonData` — they are intermediate values, not stage
results.

Expand All @@ -1128,11 +1128,11 @@ results.
description.
- **`orca.plan.Task(title, description)`** — `title` is the human-readable label
shown in the event log.
- **`orca.plan.Sessioned(chat, value)`** — every `Plan.{autonomous,
- **`orca.plan.WithChat(chat, value)`** — every `Plan.{autonomous,
interactive}.*` operation returns one: the result paired with the (ephemeral)
`Chat` that produced it, so the caller can continue that conversation in-run
or `.value` it and start fresh. Only the library builds one; destructure it
with `val Sessioned(chat, plan) = ...`.
with `val WithChat(chat, plan) = ...`.
- **`orca.plan.Verdict[A]`** — `Verdict.Proceed(value)` or
`Verdict.Rejection(kind, body)` (kind ∈ Question / Critique / Rebuff).
Returned by `assessThenPlan` as `Verdict[Plan]`.
Expand All @@ -1150,7 +1150,7 @@ results.
- **`orca.agents.Chat[B]`** — ephemeral multi-turn conversation handle from
`agent.chat()`: tool-using and workspace-editing like any agent turn ("chat"
names its lifetime, not its powers), in-run only, fork-safe. Also carried by
`Sessioned` for planning-conversation continuations.
`WithChat` for planning-conversation continuations.
- **`orca.Title`** — opaque `String` alias for short labels (`Task.title`,
`ReviewFinding.title`); `Title("…")` to construct, `.value` to read.
- **`orca.tools.PrHandle`** — handle to an open pull request (`host`, `owner`,
Expand Down
49 changes: 24 additions & 25 deletions flow/src/main/scala/orca/plan/Plan.scala
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,9 @@ import scala.annotation.unused
* with a plan or rejects), or `triage` (classify a bug report into a
* [[Triage]] verdict).
*
* Every cell returns a [[Sessioned]] — the result plus the agent session that
* produced it. A `Sessioned[Plan]` can be continued read-only into
* [[Plan.reviewed]] (self-critique), or discarded for a fresh implementer
* session.
* Every cell returns a [[WithChat]] — the result plus the chat that produced
* it. A `WithChat[Plan]` can be continued read-only into [[Plan.reviewed]]
* (self-critique), or discarded for a fresh implementer session.
*
* As a single case class it is a valid stage result (ADR 0018 §2.3) — the
* progress log, not a plan file, is what resume reads.
Expand All @@ -57,7 +56,7 @@ object Plan:
* network (issues/PRs/web) but can't edit during the planning turn (see
* [[autonomousResult]] for the per-backend guarantee).
*
* Each operation returns a [[Sessioned]]: the read-only planning session is
* Each operation returns a [[WithChat]]: the read-only planning chat is
* still resumable by a later writable call, so the caller can continue it
* into implementation or discard it for a fresh session.
*/
Expand All @@ -67,7 +66,7 @@ object Plan:
userPrompt: String,
agent: Agent[?],
instructions: String = PlanPrompts.Planning
)(using FlowContext, InStage): Sessioned[Plan] =
)(using FlowContext, InStage): WithChat[Plan] =
autonomousResult[Plan, Plan](agent, userPrompt, instructions)(identity)

/** Skeptically assess `userPrompt` (typically a bug/feature report) and
Expand All @@ -78,7 +77,7 @@ object Plan:
userPrompt: String,
agent: Agent[?],
instructions: String = PlanPrompts.AssessThenPlan
)(using FlowContext, InStage): Sessioned[Verdict[Plan]] =
)(using FlowContext, InStage): WithChat[Verdict[Plan]] =
autonomousResult[AssessedPlan, Verdict[Plan]](
agent,
userPrompt,
Expand All @@ -92,7 +91,7 @@ object Plan:
report: String,
agent: Agent[?],
instructions: String = PlanPrompts.Triage
)(using FlowContext, InStage): Sessioned[Triage] =
)(using FlowContext, InStage): WithChat[Triage] =
autonomousResult[BugTriage, Triage](agent, report, instructions)(b =>
getOrFail(b.toTriage)
)
Expand All @@ -102,9 +101,9 @@ object Plan:
* read-only: the prompt is what forbids edits here, and the user sees any
* violation. The tier was picked when read-only was believed to cost the
* `ask_user` MCP tool; claude's `--tools` allowlist does not, so this is
* revisitable. Use [[autonomous]] when no mid-session questions are needed.
* revisitable. Use [[autonomous]] when no clarifying questions are needed.
*
* Each operation returns a [[Sessioned]] so the conversation can carry into
* Each operation returns a [[WithChat]] so the conversation can carry into
* implementation.
*/
object interactive:
Expand All @@ -113,7 +112,7 @@ object Plan:
userPrompt: String,
agent: Agent[?],
instructions: String = PlanPrompts.Planning
)(using FlowContext, InStage): Sessioned[Plan] =
)(using FlowContext, InStage): WithChat[Plan] =
interactiveResult[Plan, Plan](agent, userPrompt, instructions)(
identity
)
Expand All @@ -126,7 +125,7 @@ object Plan:
userPrompt: String,
agent: Agent[?],
instructions: String = PlanPrompts.AssessThenPlan
)(using FlowContext, InStage): Sessioned[Verdict[Plan]] =
)(using FlowContext, InStage): WithChat[Verdict[Plan]] =
interactiveResult[AssessedPlan, Verdict[Plan]](
agent,
userPrompt,
Expand All @@ -140,7 +139,7 @@ object Plan:
report: String,
agent: Agent[?],
instructions: String = PlanPrompts.Triage
)(using FlowContext, InStage): Sessioned[Triage] =
)(using FlowContext, InStage): WithChat[Triage] =
interactiveResult[BugTriage, Triage](agent, report, instructions)(b =>
getOrFail(b.toTriage)
)
Expand All @@ -157,7 +156,7 @@ object Plan:
result.fold(msg => throw OrcaFlowException(msg), identity)

/** Run one autonomous turn producing wire type `O`, convert it to the public
* result `A`, and pair it with the session. Shared by every `autonomous.*`
* result `A`, and pair it with the chat. Shared by every `autonomous.*`
* operation.
*
* Runs `NetworkOnly`: reads plus read-only network, so the planner can fetch
Expand All @@ -173,7 +172,7 @@ object Plan:
)(convert: O => A)(using
@unused ctx: FlowContext,
ev: InStage
): Sessioned[A] =
): WithChat[A] =
// The planning turn runs on the restricted (NetworkOnly) sibling, but the
// chat handed out is bound to the BASE agent, so a continuation regains the
// caller's full capability — the restriction stays per-turn.
Expand All @@ -183,7 +182,7 @@ object Plan:
.resultAs[O]
.autonomous
.run(withInstructions(input, instructions))
Sessioned(chat, convert(raw))
WithChat(chat, convert(raw))

/** Interactive counterpart to [[autonomousResult]] — no per-turn restriction
* (interactive planning runs with normal permissions, see [[interactive]]),
Expand All @@ -196,19 +195,19 @@ object Plan:
)(convert: O => A)(using
@unused ctx: FlowContext,
ev: InStage
): Sessioned[A] =
): WithChat[A] =
val chat = agent.chat()
val raw = chat
.resultAs[O]
.interactive
.run(withInstructions(input, instructions))
Sessioned(chat, convert(raw))
WithChat(chat, convert(raw))

// `reviewed` resumes the planning session read-only, reusing the planner's
// `reviewed` resumes the planning chat read-only, reusing the planner's
// exploration. Defined here to keep it in the implicit scope of
// `Sessioned[Plan]`.
// `WithChat[Plan]`.

extension (sp: Sessioned[Plan])
extension (planned: WithChat[Plan])
/** Resume the planning conversation for a critical self-review, returning
* the improved plan (brief included) paired with the (same) chat. The
* review turn runs on `variant` of the read-only chat agent — the one the
Expand All @@ -218,13 +217,13 @@ object Plan:
def reviewed(
instructions: String = PlanPrompts.Review,
variant: Agent[?] => Agent[?] = identity
)(using @unused ctx: FlowContext, ev: InStage): Sessioned[Plan] =
val improved = sp.chat
)(using @unused ctx: FlowContext, ev: InStage): WithChat[Plan] =
val improved = planned.chat
.withAgent(agent => variant(agent.withReadOnly))
.resultAs[Plan]
.autonomous
.run(s"$instructions\n\n${render(sp.value)}")
Sessioned(sp.chat, improved)
.run(s"$instructions\n\n${render(planned.value)}")
WithChat(planned.chat, improved)

/** Empty plans render as nothing — surfacing "0 tasks planned" muddies the
* picture; a planning failure is more useful as an explicit `fail(...)` from
Expand Down
4 changes: 2 additions & 2 deletions flow/src/main/scala/orca/plan/PlanPrompts.scala
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ object PlanPrompts:
val Triage: String =
PromptResource.load("/orca/plan/prompts/triage.md")

/** Used by `Sessioned[Plan].reviewed`. The current plan is appended after
* this block; the agent returns an improved plan, brief included.
/** Used by `WithChat[Plan].reviewed`. The current plan is appended after this
* block; the agent returns an improved plan, brief included.
*/
val Review: String =
PromptResource.load("/orca/plan/prompts/review.md")
2 changes: 1 addition & 1 deletion flow/src/main/scala/orca/plan/Triage.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import orca.agents.{Announce, JsonData}
* with runtime `Option#get` / empty-string checks.
*
* Produced by [[Plan.autonomous.triage]] / [[Plan.interactive.triage]],
* wrapped in a [[Sessioned]]. Flows typically discard the triage session
* wrapped in a [[WithChat]]. Flows typically discard the triage chat
* (`.value`) and seed a fresh implementer session from the issue body.
*
* A `stage` can record and replay a `Triage` result — the triage stage is a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ package orca.plan
import orca.agents.Chat

/** A planning-phase result paired with the (ephemeral) [[orca.agents.Chat]]
* that produced it. "Sessioned" names this ephemeral pairing specifically — it
* is not a durable session; see `agent.session(name, seed)` for that.
* that produced it.
*
* Every `Plan.{autonomous,interactive}.*` operation returns one of these, so
* the caller can continue the same conversation into the implementation phase
Expand All @@ -21,7 +20,7 @@ import orca.agents.Chat
* site:
*
* {{{
* val Sessioned(chat, plan) = Plan.autonomous.from(userPrompt, claude)
* val WithChat(chat, plan) = Plan.autonomous.from(userPrompt, claude)
* }}}
*/
final case class Sessioned[+A] private[orca] (chat: Chat[?], value: A)
final case class WithChat[+A] private[orca] (chat: Chat[?], value: A)
10 changes: 5 additions & 5 deletions flow/src/test/scala/orca/plan/PlanGridTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ package orca.plan
import orca.events.EventDispatcher

/** Runtime wiring of the autonomous planning grid: each operation pairs its
* result with the producing session, and `triage` converts the wire
* [[BugTriage]] into a [[Triage]]. The conversions themselves are covered by
* result with the producing chat, and `triage` converts the wire [[BugTriage]]
* into a [[Triage]]. The conversions themselves are covered by
* [[AssessThenPlanTest]] (toVerdict) and [[BugTriageTest]] (toTriage); the
* interactive cells share the same helper and are pinned at compile time by
* `flowtests.FlowCompilesTest`.
Expand Down Expand Up @@ -70,15 +70,15 @@ class PlanGridTest extends munit.FunSuite:
"the planning turn must run on the restricted sibling"
)

// --- post-planning step (reviewed) on the planning session ---
// --- post-planning step (reviewed) on the planning chat ---

/** `samplePlan` on a planning chat whose agent answers `reply`, after the
* planning turn that opened its conversation.
*/
private def planned(reply: CannedResult[Plan]): Sessioned[Plan] =
private def planned(reply: CannedResult[Plan]): WithChat[Plan] =
val chat = reply.agent.chat()
val _ = chat.resultAs[Plan].autonomous.run("plan")
Sessioned(chat, samplePlan)
WithChat(chat, samplePlan)

test("reviewed returns the improved plan on the original chat binding"):
val improved = samplePlan.copy(description = "tighter", brief = "sharper")
Expand Down
2 changes: 1 addition & 1 deletion runner/src/main/scala/orca/exports.scala
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export orca.agents.{
schemaFromJsonData,
codecFromJsonData
}
export orca.plan.{BugReportMatch, Plan, Sessioned, Task, Triage, Verdict}
export orca.plan.{BugReportMatch, Plan, Task, Triage, Verdict, WithChat}
// PrSummary is the result type of openPrFromBranch and summarisePr;
// orcaCommentMarker is the idempotency marker gh.upsertComment keys on;
// recordOpenedPr is for a flow that opens its PR with a bare gh.createPr, and
Expand Down
8 changes: 4 additions & 4 deletions runner/src/test/scala/flowtests/ChatAdoptionTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,20 @@ class ChatAdoptionTest extends munit.FunSuite:
test("a flow script cannot pair a value with a chat"):
val errors = compileErrors(
"""
def pair(c: orca.Chat[?], p: orca.plan.Plan) = orca.plan.Sessioned(c, p)
def pair(c: orca.Chat[?], p: orca.plan.Plan) = orca.plan.WithChat(c, p)
"""
)
assert(
errors.contains(
"Sessioned in package orca.plan does not take parameters"
"WithChat in package orca.plan does not take parameters"
),
errors
)

test("a flow script cannot swap a Sessioned's chat"):
test("a flow script cannot swap a WithChat's chat"):
val errors = compileErrors(
"""
def swap(s: orca.plan.Sessioned[orca.plan.Plan], c: orca.Chat[?]) =
def swap(s: orca.plan.WithChat[orca.plan.Plan], c: orca.Chat[?]) =
s.copy(chat = c)
"""
)
Expand Down
Loading
Loading