Skip to content

feat(core): let agents ask another session a question - #60074

Open
BHIIKTOR wants to merge 3 commits into
anomalyco:devfrom
BHIIKTOR:session-ask
Open

BHIIKTOR wants to merge 3 commits into
anomalyco:devfrom
BHIIKTOR:session-ask

Conversation

@BHIIKTOR

@BHIIKTOR BHIIKTOR commented Oct 29, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #60072

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Multi-session work is normal today: subagents, forks, background jobs, and the desktop voice delegator all run beside the session that started them. But the only sanctioned cross-session path is parent to child, so an agent that needs an answer from a sibling session improvises through a shared file or asks a human to relay it.

This adds two native tools over the existing session inbox. ask({session, question}) resolves an existing top-level session by exact id or exact title, lists metadata only, and queues one bounded question into that session's inbox. answer({replyTo, answer}) lets the target reply once back to the asker as a message. Texts are capped at 4000 characters, three questions per pair, and no message text or system prompt is read; the duplicate-answer guard compares stored metadata only. Zero schema, protocol, table, or client changes.

Core is the right home because the voice delegator already messages sessions by reading their transcripts. An agent tool should read no message text and be cooperative: the target decides whether to answer, and answers with a tool call.

How did you verify your code works?

bun test test/tool-ask.test.ts — 7 tests covering admission (queued synthetic, envelope, metadata), every refusal class (self, child caller/target, archived, cross-project, unknown, ambiguous with ≤10 candidates), single-answer enforcement (forged replyTo and non-ask synthetics refused, second answer refused), the 3-per-pair and duplicate-pending caps, inbox promotion making the question model-visible, and permission denial through an agent ruleset. The exhaustive built-in-tool catalog test is updated for the two new names. bun run typecheck:tsc, oxlint, prettier, and ast-grep are clean. The remaining full-suite failures on this machine reproduce on the unmodified tree (filesystem watchers on system paths, MCP/SSE timeouts) and are unrelated.

Screenshots / recordings

Not a UI change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Related: #58367, #52297, #57191

Public API: adds built-in ask and answer tools and their permission keys (backwards compatible)

Two native tools over the existing session inbox. ask({session, question})
resolves an existing top-level session by exact id or exact title, lists
metadata only, and queues one bounded question into that session's inbox.
answer({replyTo, answer}) lets the target reply once back to the asker as a
message. Texts are capped at 4000 characters, three questions per pair, and
no message text or system prompt is read; the duplicate-answer guard
compares stored metadata only. Zero schema, protocol, table, or client
changes.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The basic ask → promote → answer flow works: the PR's tests pass, typecheck:tsc in packages/core is clean, and a small probe test in the same harness confirmed it. That probe also showed that two of the limits the PR describes don't hold, and that title lookup shows sessions from other projects. Details are inline.

Public API: adds two built-in tools and permission keys, ask and answer. This is additive and backwards compatible. Unknown permission keys fall back to ask, so nothing existing changes behaviour.

Concerns, most important first:

  1. Title lookup isn't scoped to the project. sessions.list is called without project, parentID: null or a directory filter, and search is a SQL LIKE '%…%'. So ask({session: "a"}) lists ids and titles of sessions in other projects and of subagent sessions. A title that exists once here and once in another project is refused as ambiguous. And with limit: 10, the right session can be cut off by sessions from other projects.
  2. The "3 unanswered per pair" cap and the duplicate check only count questions still in the inbox. Once the target promotes a question into its history, it stops counting. I could queue far more than 3 unanswered questions, including re-asking the same one. Both tools also wake the other session (resume: true), so two agents can keep asking and answering each other with nothing stopping it.
  3. Overlap with #60042. It adds metadata["opencode.origin"] so the agent knows a message came from another session. It would be worth aligning the question and answer messages with it rather than adding a separate source: "ask" format. #60072 also asked for maintainer sign-off on the design (enabled by default as a core tool, addressing sessions by exact title), and none has been given yet.

Related: #58367, #52297, #57191

Comment thread packages/core/src/tool/plugin/ask.ts Outdated
})
return found.value
}
const candidates = (yield* sessions.list({ search: reference, limit: 10, archived: false }).pipe(Effect.orDie)).data

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This lookup isn't limited to the caller's project or to top-level sessions, and search is a LIKE '%…%' match. I ran ask({session: "a"}) from a session in project A, and the error listed a project-B session's id and title as a candidate. A title that exists once here and once in another project is reported as ambiguous, even though the other one would be refused anyway. With limit: 10, the right match can also be pushed out by sessions from other projects or by child sessions. Filtering by project: caller.projectID and parentID: null would fix all three. If %/_ in titles matter, use an exact title match rather than search.

Comment thread packages/core/src/tool/plugin/ask.ts Outdated
return yield* new ToolFailure({ message: `Session ${target.id} belongs to another project.` })
if (target.time.archived) return yield* new ToolFailure({ message: `Session ${target.id} is archived.` })

const pending = pendingQuestions(yield* sessions.inbox(target.id).pipe(Effect.orDie), caller.id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sessions.inbox only returns items that haven't been promoted yet. Once the target starts a step and promotes the question into history, it no longer counts here, even though it's still unanswered. In a probe that asked 3 times and then promoted, each promotion freed a slot, and re-asking the same unanswered question was accepted. So the cap limits the queue, not unanswered questions, which is what the docs promise. To make it hold, this check also needs to count promoted questions in the target's history that have no matching answer yet.

Comment thread packages/core/src/tool/plugin/ask.ts Outdated
.synthetic({
sessionID: asker.value.id,
delivery: "steer",
resume: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both the question (line 146) and the answer use resume: true, so each wakes the other session. Combined with the cap resetting after promotion, two agents can ask and answer each other in a loop with no user involved, starting model runs in sessions nobody may be watching. It needs an actual limit: count every unanswered question (not just queued ones), add a per-pair total or rate limit, or don't auto-resume the asker on answers.

Comment thread packages/web/src/content/docs/tools.mdx Outdated

The `session` argument is an exact session ID or an exact title. Discovery is metadata only: the tool lists session IDs and titles, and never reads another session's messages or system prompt. The current session, a caller or target that is a child session, and archived, cross-project, unknown, or ambiguous targets are refused. An ambiguous title is refused with up to 10 matching candidates.

The target sees the question framed as data, not as a user instruction, and the answer is an observation, not approval. Each text is capped at 4000 characters, a session pair can hold at most 3 unanswered questions, and asking an identical question while it is still pending is refused.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code doesn't enforce "at most 3 unanswered questions" yet (see the comment on ask.ts line 128). Today it's at most 3 questions still waiting in the target's inbox.

Resolve titles among top-level sessions in the caller's project and compare
them exactly, so other projects, child sessions, and same-title sessions
elsewhere never appear as candidates or turn into a false ambiguity.

Count unanswered questions across the target's inbox and promoted history,
then match them against answers recorded in the asking session, so the
three-question cap and the duplicate guard survive delivery.

Stop the answer leg from waking the asker: replies are steered without
resume so two sessions cannot ping-pong into unattended runs. The question
leg still wakes the target, and the ask tool says the answer arrives at the
asker's next step.

Stamp `opencode.origin` ({kind, sessionID, title}) on both legs, matching
the shared cross-session provenance key in anomalyco#60042. That PR is still open, so
the same shape is written literally rather than imported.
@BHIIKTOR

Copy link
Copy Markdown
Author

Thanks — all three addressed in 6610ed6:

  1. Scoped lookup. Discovery now passes project (caller's) and parentID: null to sessions.list, then exact-equality title matching (the store has no exact-title predicate; search is only used to pre-narrow). Candidates can no longer contain other projects' sessions or child sessions, so the cross-project false-ambiguity and crowd-out cases are gone rather than filtered.

  2. Cap counts unanswered, and the loop is broken. Unanswered is now questionsTo(target) − answerReplyTos(asker) across inbox and promoted history, metadata only — the cap and the duplicate guard survive promotion, and a recorded answer frees the slot. Same rule backs answer's already-answered check. Questions still wake the target (a deliberate ask), but answers no longer wake the asker: the reply is delivery: "steer", resume: false, so it arrives at the asker's next step boundary and two agents cannot ping-pong unattended.

  3. opencode.origin alignment. Both legs now stamp metadata["opencode.origin"] = { kind: "session", sessionID, title } in the same shape as feat(core): tell the agent who sent a message (experimental) #60042, alongside the ask-specific keys (source: "ask", kind, replyTo) that carry semantics it doesn't cover. feat(core): tell the agent who sent a message (experimental) #60042 is still open, so the key is written literally rather than imported; happy to switch to its constant once it lands.

Docs follow the code (scoped resolution, unanswered-not-queued wording, no-wake-on-answer). Tests grew 7 → 11 with the new behaviors; every behavior test fails against the previous commit. bun run check is 35/35 under the pinned bun.

The design questions on #60072 (enabled-by-default core tool, exact-title addressing) are still open for maintainer sign-off — the code changes here are independent of that decision.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commit addresses all four earlier points. I re-ran my probes in the tool-ask harness. Title lookup now only considers top-level sessions in the caller's project, and same-titled sessions in another project no longer make it ambiguous. The 3-unanswered cap and the duplicate check still hold after the target promotes its questions. Answers no longer wake the asking session. The tool-ask and location-layer tests pass, and typecheck:tsc in packages/core is clean.

The public API change is as before: it adds the ask and answer built-in tools and their permission keys, which is backwards compatible.

Two smaller points:

  • opencode.origin is written here before #60042 has merged, and that PR fills in the sender's title itself, so the two need to agree (see inline).
  • Unanswered questions never expire. If the target never answers, that pair of sessions stays capped for good. That's fine if it's intended.

Comment thread packages/core/src/tool/plugin/ask.ts Outdated
const readAnswer = Schema.decodeUnknownOption(AnswerMetadata)

// The shared cross-session provenance key for session-originated messages (#60042), stamped literally until it merges.
const origin = (session: SessionSchema.Info) => ({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This writes opencode.origin itself before #60042 has merged, and includes title. #60042 sets the origin title at admission from the sender session, not from the caller. If its shape or rules change before it lands, these messages will carry a mismatched origin. It might be simpler to leave it out here and adopt it once #60042 merges, or to make sure the two land in agreement.

The self-stamped origin would drift from anomalyco#60042, which sets the title server-side at admission; adopt the shared key once that PR merges.
@BHIIKTOR

Copy link
Copy Markdown
Author

Agreed — dropped the opencode.origin stamp entirely in 1af3634 rather than risking a mismatched shape. The ask-specific metadata (source, kind, from, to, replyTo) and the envelope text carry everything the feature needs today; we'll adopt the shared key once #60042 lands.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latest commit removes the hand-written opencode.origin metadata, which resolves the remaining point about #60042. The tool-ask and location-layer tests pass, and typecheck:tsc in packages/core is clean. No new concerns.

The public API change is unchanged from before: it adds the ask and answer built-in tools and their permission keys, which is backwards compatible.

@ryanmeador

Copy link
Copy Markdown

Thanks for working on this! I'm excited to see inter-agent communication getting attention, but I think "ask"/"answer" is unnecessarily restrictive as the underlying abstraction.

General-purpose agent messaging is already established in Claude Code's Agent Teams ("SendMessage") and GitHub Copilot/VS Code. OpenCode also has a proposal for a generic "send" tool in #58367. Why introduce question/answer semantics when arbitrary messaging can support that use case and many others, including notifications, progress updates, and unsolicited findings?

I've been extending opencode-team-plugin with independent session spawning, worktree-aware discovery, and better message visibility. The approach works reasonably well as a plugin, but would benefit from first-class integration with OpenCode.

I'd much rather see OpenCode adopt a general-purpose messaging primitive, with question/answer as an optional higher-level pattern.

@BHIIKTOR

Copy link
Copy Markdown
Author

i don't like the ask/answer commands either but i think it gives a degree of manual control that avoids full turbo slop.
i think is key that the initiation of the conversation is the manual, i dont want agents talking freely because it will lead to unexpected behavior.
it does and feels cool if they did, but control is something i want to keep.
on my local version i have a time lease and a total amount of messages allowance for more control.
this all above and what you mention is one of the reasons i think this features is really hard to tackle, because there are so many ifs of the how and what to avoid for unexpected results (ie cross session contamination and unrelated combos).
i proposed something purely to make a conversation and put it out there for the community :)
thanks for the feedback!

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Let agents ask another session a question

2 participants