Skip to content

feat(harness): state, carry and switch the scope choices Search settles - #88

Merged
jm-rivera merged 34 commits into
devfrom
feat/scope-choices
Oct 27, 2026
Merged

jm-rivera merged 34 commits into
devfrom
feat/scope-choices

Conversation

@jm-rivera

Copy link
Copy Markdown
Contributor

The harness now keeps the scope choices Search reports for each part (ResolvedSpec.scope_choices, mirrored in harness/scope_choices.py) on every stored reading, states them in the answer, and lets the user switch them on the plan card. Every answer closes with a code-written "Counted as:" line built from what the fetched frame served (turn_records.basis_line), stored apart from the model's text so the next turn's model never reads or copies it. Later searches carry the user's own choices as carried_entries, with the user's words, through data-mcp's search _meta (org.one.dfo/search_context), gated by the data-mcp setting search_sends_conversation_context, which defaults off.

The plan card shows each scope choice as a decision with its options and the reason an option is unavailable (PlanDecision.choice_id, active_option_id, options). A switch (InterruptDecision.SWITCH, node graph/switch.py) resubmits the stored base with the switched entry through Search, stores the new reading, revises the plan in code with no model call, and re-offers the card with what moved. A stale switch or approve is refused 409 plan_revision_stale carrying the current card, and approve now accepts plan_revision; docs/frontend-interface.md section 4.2 has the wire.

A calculation reading several inputs (share, quotient, sum, comparison, join) pins every input and puts them on one measure basis before the plan records: a basis the user stated wins, else the narrowest input's. Inputs are recompiled through the same resubmit a switch uses; an input that cannot move refuses the plan. Choices the planner's own words or alignment settled are marked on the spec block (_planner_axes) and never carried or credited as the user's. Over several years with prices unstated, the search result states open_prices in code, and the orchestrator asks prices from that field, since a settled basis no longer opens a gross-or-net question beside it.

Reviewers should challenge submission.service._aligned_bases, the switch node, and planner.plan.identity_on_record, which is now the one lookup of a pin's identity for admission and the translator.

Merge with the dfo-apis scope-choices PR, which serves the record and the resubmit entries. search_sends_conversation_context is turned on in dfo-infra once dfo-apis is deployed. The planner_entry recordings need re-recording against the deployed Search.

Testing: targeted harness and data-mcp test files pass locally; the full suite runs on CI. Live, with Search from the dfo-apis branch: the share plan switched to net moves both inputs (14.0%, $8.29bn of $59.2bn), a sector switch moves only health, a stale switch gets the current card, "total health aid from the US to Africa in 2024" answers $5.81bn and "now Ghana" $78.5m with the sector carried, and both live region and prices evals pass 3 of 3.

SearchReading keeps each part's resolved.scope_choices, persisted with
the turn and rendered as one ledger line per choice for the planner and
recall. The model-facing part summary is unchanged. Parsed searches send
the user's own message (from a contextvar set at turn start and on
resume) and the conversation's stated choices as carried entries, which
data-mcp forwards to the Search API only while
search_sends_conversation_context is on.
Send the newest 4,000 characters of the user message, so appended
amendments survive, and the 16 newest carried entries, matching the
Search API limits.
The digest prints one line per scope choice (axis, active option,
provenance and the other options a client can ask for), and the
structured answer carries the same record. The region eval accepts a
summed region with its own regional and unspecified buckets, and the
count-and-prices eval no longer expects a gross or net question where
the convention table settles the basis.
fetch and the plan execute node keep a compact record of what was
served (members and residuals per role, measure basis, components) on
the reading. finish() appends one "Counted as:" line built from the
stored scope choices and their search labels, corrected where the
served figures depart, beside the loose-match notes, so it reaches the
stored turn and the next turn's history. The planner's ledger text is
unchanged.
propose_plan writes, in code, one decision per scope choice on each
pinned source that has another available or partial option, citing it
from the requirements that pin the source, user-resolved for explicit or
carried choices and planner default otherwise. Decisions past the plan
cap are left to the basis line. The decision keeps each option's
availability, so the card and a later switch read from the plan alone,
and the Studio wire carries the choice id, the active option and the
options with reader reasons.
…nner model

A switch answer on a plan pause is checked against the pending card (a
stale revision is refused 409, an option not offered or unavailable
422) and routed to a deterministic node. The node posts each affected
source's base identity with its scope entries and the switch to the
Search API through data-mcp, which registers the compiled definition,
then revises the plan through propose_plan's own chain with no model
call and offers it again with what moved. A switch writes no approval,
and a refusal re-offers the unchanged plan with a note.
…search the user's own words

A fetch narrowed to some of a region's places drops the region clause;
a plan reading a windowed copy states the line of the reading it was
copied from. A switch label stays in the turn record but never reaches
search as the user's message. The scope record is read leniently, so a
field the Search API adds never drops the choices. data-mcp types each
carried entry and logs whether it forwards the conversation context.
A switch or an approve naming a plan revision the user no longer sees is
refused 409 plan_revision_stale with the pending plan card, stored with
the pause exactly as the stream sent it, so a client replaces its card
instead of acting on a plan it has not seen. Approve without a revision
behaves as before.
A share, ratio, sum, comparison or join reading several sources has its
inputs put on one basis before the plan is recorded, unless the user
stated different bases on them. The target is the basis the user stated,
else the narrowest input's; other inputs are recompiled through the
same Search resubmit a plan-card switch uses, with no model call, and
stored as readings. A basis the planner chose this way is marked on its
spec block, so it never carries into later searches as the user's
choice. An input that cannot be counted on the target refuses the plan
as operation_unsupported, naming the mismatch.
…of history and label switched inputs by their source

The translator reads a pinned identity from the pin's own spec block,
so a source a switch or basis alignment compiled during the request is
fetched as pinned. The turn record stores the basis line apart from the
answer, so the next turn's model never sees it to copy. A step reading
a dataset governed by a basis or sector choice names its input as
Search counts the served source.
…ne basis

An input of a multi-input calculation that the planner left unpinned is
searched in code with the words the translator would send, pinned to
what Search serves and stored as a reading, so a basis alignment or a
switch moves it with the rest. Code-appended reply notes are stored
apart from the answer, like the basis line, so the next turn's model
never reads them.
…sks them

A definite search part whose window spans several years, whose unit is
money in current prices and whose prices the user left unstated carries
open_prices, the options of its prices question with search's reading
first. The orchestrator asks prices from that field, so the question no
longer depends on another counting choice being open beside it.
…frames

get_data states each component of a composite frame by counts and year
spans, and the change of measure at 2018 in a spliced one; component
member lists are bounded like resolved ones. The basis line names an
inclusive frame as bilateral plus imputed multilateral with its years,
and a headline frame as net to 2017 and grant equivalents from 2018. A
calculation puts its inputs on one provider scope as it does one basis,
and refuses a sum of inputs the user stated on different providers. An
analysis verb given a composite frame points to the summed get_data
read, where dfo-apis adds the components once.
…nswer

After a fast-path answer, Studio suggests up to four chips, one for
each available option of the scope choices the answer read. A chip
turn resubmits the stored base with the chosen option through the same
path a plan-card switch uses, stores the reading, fetches the compiled
spec and answers with the basis line, with no search or plan call. A
stale or unavailable chip is refused before the turn starts. Chips
shown, applied, kept and reversed are recorded as telemetry events.
The fetch arguments that shape a frame are stored on the reading, and a
chip turn fetches the switched spec with them, so a chip changes only
the chosen reading. An answer that named its places offers no chip on a
choice that binds places, since the refetch would not change them.
…anner's

A spec block marks the scope axes the planner's own words or alignment
settled. Those choices never carry into later searches as the user's,
never count as a basis the user stated, and show on the plan card as
the planner's. A calculation input that search cannot settle to one
dataset refuses the plan, since one basis cannot be guaranteed.
# Conflicts:
#	services/harness/src/harness/graph/switch.py
#	services/harness/src/harness/orchestrator_tools.py
#	services/harness/src/harness/planner_seam.py
#	services/harness/src/harness/scope_choices.py
#	services/harness/src/harness/tool_contracts.py
#	services/harness/src/harness/turn_records.py
#	services/harness/src/planner/agent.py
#	services/harness/src/submission/service.py
#	services/harness/tests/planner/test_agent.py
#	services/harness/tests/test_graph.py
#	services/harness/tests/test_orchestrator_tools.py
# Conflicts:
#	services/harness/src/harness/planner_seam.py
#	services/harness/src/harness/scope_choices.py
#	services/harness/src/planner/agent.py
#	services/harness/src/submission/service.py
…ames a message or a chip

A reading keeps the arguments of each fetch that returned data, and a
chip fetches the switched spec once per entry, so an answer that read
Ghana and then Kenya is switched for both. A turn request carrying
neither a message nor a chip is refused at the boundary.
After a switch or a basis alignment, the basis line names the part by
the label Search gives the served pin, so the name never states a basis
the clause contradicts. A part still on its first pin keeps the words it
was asked in.
# Conflicts:
#	services/harness/src/harness/turn_records.py
#	services/harness/tests/test_turn_records.py
# Conflicts:
#	services/harness/src/planner/agent.py
#	services/harness/src/planner/tool_results.py
#	services/harness/src/submission/service.py
#	services/harness/src/translator/discovery.py
#	services/harness/tests/planner/test_agent.py
#	services/harness/tests/test_plan_display.py
…mpile each alignment once

The search digest and its structured content state each entry Search
dropped: the option asked for, its provenance, the option served and the
cause in words, so an MCP client learns why an explicit request was not
applied. The planner compiles each source and option set once, however
many requirements ask for it.
A chip names the turn whose answer offered it and the reading it
switches, and is refused as stale once a later turn has answered or a
later reading holds its choice, so an old chip never switches a reading
the conversation has moved past.
# Conflicts:
#	services/harness/src/planner/agent.py
A realignment names one option per axis an input moves on, so an input
counted gross and inclusive can become net and bilateral in one
resolve_definition resubmit carrying both entries. The compiled pin is
keyed by the origin and that option set, and inputs sharing both compile
once.
…ested calculations as one group

A retryable Search refusal for an unpinned input is answered as
retryable and never held. Calculations sharing an input are aligned as
one connected group, a basis the user chose in an earlier turn counts as
stated, the decision cap counts only decisions that survive a re-pin,
and the input search and compile take the time the entry has left. A
scope record from a newer Search keeps every part it can read. The plan
switch resubmits through resolve_definition with the groups every
resubmit posts, and data_mcp.scope holds the one definition of scope
entries, axes and reason wording.
# Conflicts:
#	services/harness/src/harness/scope_choices.py
#	services/harness/src/planner/agent.py
#	services/harness/src/submission/service.py
…over disjoint donors

A chip resubmits with the groups its reading was resolved with, is
refused stale when seed finds its choice on another reading, is offered
only on the newest reading holding its choice, and fetches through the
failure handling every agent fetch gets. A sum over different stated
provider scopes is refused only when its inputs share a donor or one
counts every provider. A composite refusal names the side whose
components hold the refused StatVars, and common.composite holds the one
reader of trace components and year spans.
# Conflicts:
#	services/harness/src/planner/agent.py
@jm-rivera
jm-rivera marked this pull request as ready for review October 27, 2026 10:49
@jm-rivera
jm-rivera marked this pull request as draft October 27, 2026 10:50
@jm-rivera
jm-rivera marked this pull request as ready for review October 27, 2026 10:50
… scope decision fields

The planner-entry eval takes the Resolver protocol from
harness.orchestrator_tools, so its recorded and recording resolvers
accept and replay the search context the real one sends, and its probe
passes revise through. The revision base format lists DecisionScope and
ScopeOptionState.
@jm-rivera
jm-rivera merged commit 810c53d into dev Oct 27, 2026
3 checks passed
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.

1 participant