Repository navigation
Conversation
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.
There was a problem hiding this comment.
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:
- Title lookup isn't scoped to the project.
sessions.listis called withoutproject,parentID: nullor a directory filter, andsearchis a SQLLIKE '%…%'. Soask({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 withlimit: 10, the right session can be cut off by sessions from other projects. - 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. - 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 separatesource: "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.
| }) | ||
| return found.value | ||
| } | ||
| const candidates = (yield* sessions.list({ search: reference, limit: 10, archived: false }).pipe(Effect.orDie)).data |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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.
| .synthetic({ | ||
| sessionID: asker.value.id, | ||
| delivery: "steer", | ||
| resume: true, |
There was a problem hiding this comment.
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.
|
|
||
| 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. |
There was a problem hiding this comment.
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.
|
Thanks — all three addressed in 6610ed6:
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. 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. |
There was a problem hiding this comment.
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.originis 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.
| 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) => ({ |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
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. |
|
i don't like the ask/answer commands either but i think it gives a degree of manual control that avoids full turbo slop. |
Issue for this PR
Closes #60072
Type of change
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 (forgedreplyToand 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
Added by the opencode bot (AI-generated).
Related: #58367, #52297, #57191
Public API: adds built-in
askandanswertools and their permission keys (backwards compatible)