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
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

66 changes: 66 additions & 0 deletions packages/protocol/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,72 @@ went 6.7.1 → 8.0.2 with no 7.x in between because the whole 7.x line shipped a
prereleases between the two promotions. To track every change, read `rc`; to
pin a supported release, use `latest`.

## 23.5.1 - 2026-08-20

### Fixed

- **The negotiation listing says the park, from the same record every other
answerability surface reads.** Observed live: a client asked her agent "do we
have a question?" while a negotiation had sat parked `input_required` on her
side for two hours with the open question "Timing: This week". Every 23.5.0
surface was correct — the precedence gate found the question, and the
prompt's open-questions section named it at position 1. Then the model called
`list_negotiations`, which renders lifecycle from OPPORTUNITY STATUS, where
the pairing legitimately reads `negotiating`, and which said nothing at all
about the park. Holding a static context line saying one thing and a
just-executed tool result saying another, it went with the tool: "there are
currently no open questions… I am still negotiating with the other agent…
nothing for you to decide." Both clauses false at the task level; both
faithful to what the tool rendered.

The listing was the last surface still deriving "what is happening" from a
source other than the shared resolver, so the tool and the context could
disagree — and the tool wins the model's trust every time. It now carries a
`park` on any negotiation that holds one: `waitingOn: "you" | "counterparty"`,
and for a park on the client's own side the open question's number and label.

- **One call, every surface — the answer-openness rule extended to its last
holdout.** The question's NUMBER and LABEL come from
`NegotiationListingParkHost`, whose host implementation resolves them through
the same call the open-questions prompt section and `answer_pending_question`
make. The listing does not enumerate anything of its own, so the number the
client is shown is the number that routes their answer. A question's
`alsoUnblocks` refs carry that same number, since one answer resumes them all.

- **Whose side a park is on is the canonical predicate, not a second one.**
`classifyParkedNegotiation` is split into `classifyInflightPark` and
`classifyPostStallPark`, pure over the task and messages a caller already
holds; the async function is now the reading half around them and keeps its
lazy messages read. The listing calls them with material it already loaded, so
park classification costs no extra query and cannot drift from the predicate
answer routing uses. Parked-ness is never re-derived from opportunity status.

- **A park on the counterparty is narrated, never quoted.** It renders as
waiting on their side with no question content: that question is not this
client's to read.

### Changed

- **`lifecycleLabel` states the park, superseding the status label.** The
persona is told to take `lifecycleLabel` as its user-facing wording, and "the
agents are still negotiating" is exactly the sentence that became a false
"nothing for you to decide". `opportunityStatus` still reports the true
status; what it may no longer do is supply the sentence.
`connectionState` gains `parked_awaiting_your_answer` and
`parked_awaiting_counterparty`, and `buildLifecycleNarration` takes an
optional third argument. A negotiation with no park renders byte-for-byte as
it did in 23.5.0, and is pinned by a fixture spec.

- **The tool description and the negotiator prompt both say the two agree.**
`list_negotiations` documents `input_required` as a status, documents `park`,
and states that the numbers come from the same record the open-questions
context section does — so the model has no basis to rank one over the other.
It also states that `negotiating` alone never means "nothing is waiting on
you". The persona gains one grounding rule saying the same thing.
`NegotiationListingParkHost` is optional: without it the listing still says
whether a pairing is parked and on whose side, it just cannot name the
question's number.

## 23.5.0 - 2026-08-20

### Added
Expand Down
2 changes: 1 addition & 1 deletion packages/protocol/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@indexnetwork/protocol",
"version": "23.5.0",
"version": "23.5.1",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down
3 changes: 2 additions & 1 deletion packages/protocol/src/chat/negotiator.prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ ${profileContext}

| Tool | Params | What it does |
|------|--------|-------------|
| **list_negotiations** | status?, scope?, limit?, detail? | List agent negotiations with lifecycle-explicit opportunity and owner-action labels (clamped to the pinned signal when one is set; pass scope:'all' for full history) |
| **list_negotiations** | status?, scope?, limit?, detail? | List agent negotiations with lifecycle-explicit opportunity and owner-action labels, and a \`park\` on any negotiation waiting on a person (clamped to the pinned signal when one is set; pass scope:'all' for full history) |
| **get_negotiation** | negotiationId | Full negotiation record: messages, outcome, reasoning |
| **respond_to_negotiation** | negotiationId, ... | Act on a negotiation — ONLY on explicit client instruction |${opportunityListingToolRow}
| **update_opportunity** | opportunityId, status | Accept/pass an opportunity — ONLY on explicit client instruction |
Expand All @@ -296,6 +296,7 @@ ${profileContext}
- **Never fabricate.** Every claim about a negotiation, opportunity, signal, or premise must come from a tool result in this conversation. If you have not looked it up this turn, look it up before answering. Only the client's identity and profile above are preloaded.
- **Check tool results before confirming.** Never claim an action succeeded without a successful tool result for it.
- **Be honest about your own actions.** If the record shows you made a judgment call the client disagrees with, explain the reasoning from the record — do not get defensive, and do not invent justifications the record does not support.
- **A parked negotiation is waiting on the client, whatever its status says.** \`list_negotiations\` marks any negotiation that is parked with a \`park\` object, and for a park on the client’s own side it names the open question by the SAME number the open-questions section of this prompt shows — both come from one record, so neither can override the other and there is nothing to rank. Opportunity status does not answer this question: a parked pairing still reads \`negotiating\`, so never take \`negotiating\`, or the absence of an item you were looking for, as “nothing is waiting on you”. When the client asks whether anything needs them, \`park.waitingOn="you"\` is a yes — name the question. A park on the counterparty’s side means the opposite: say it is waiting on their side, and never quote a question that is not the client’s to read.
- **Keep lifecycle states distinct.** A negotiation task with status \`completed\` means only that the agents concluded. Use the tool's \`lifecycle\` object and \`lifecycleLabel\` for user-facing wording. If the opportunity is \`pending\`, say the agents concluded with a potential match awaiting the owner's review. Agent-turn \`accept\`, \`latestAction=accept\`, and \`outcome.hasOpportunity=true\` are agent-side judgments: never translate them into “I accepted”, “you accepted”, “connected”, “completed connection”, or equivalent. Describe rejected, stalled, draft, expired, pending, and accepted opportunities separately; never aggregate them as completed connections.
- **Owner actions require explicit evidence.** Say the owner accepted only when \`lifecycle.ownerAction=accepted\`. This reporting contract does not prove an owner pass, so a rejected opportunity must not be narrated as “you passed” unless a separate current-turn tool result explicitly establishes that owner action. Reporting and history narration are read-only; call \`update_opportunity\` only for the client's explicit current instruction.
- **Never infer a direct chat.** Negotiation completion and every opportunity status, including \`accepted\`, are insufficient evidence that an H2H conversation or message thread exists. A \`conversationId\` with \`conversationType=agent_negotiation\` identifies only the A2A agent transcript. \`lifecycle.directConversationEvidence=not_provided\` means do not mention messages. Mention a direct conversation only when a current-turn tool result independently and explicitly supplies H2H conversation evidence.${proposalCardGuidance}
Expand Down
4 changes: 4 additions & 0 deletions packages/protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,9 @@ export {
// ─── Negotiation answer consumption (conversational questions) ──────────────

export {
classifyInflightPark,
classifyParkedNegotiation,
classifyPostStallPark,
consumeQuestionBlockAnswers,
negotiationParkAnswerId,
resumeParkedNegotiation,
Expand All @@ -362,6 +364,8 @@ export type {
NegotiationAnswerInput,
NegotiationAnswerResumeOutcome,
ParkClassification,
ParkClassificationMessage,
ParkClassificationTask,
QuestionBlockAnswerConsumptionInput,
QuestionBlockAnswerConsumptionResult,
RoutedAnswer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,80 @@ export type ParkClassification =
/** Parked, but awaiting the OTHER side's client — this user's answer must not resume it. */
| { kind: "wrong_recipient" };

/** A negotiation task, reduced to what park classification reads off it. */
export interface ParkClassificationTask {
id: string;
state: string;
metadata: Record<string, unknown> | null;
}

/** A negotiation's own turn messages, reduced to what post-stall classification reads. */
export interface ParkClassificationMessage {
senderId: string;
parts: unknown[];
taskId?: string | null;
}

/**
* The park an `input_required` negotiation task holds, classified from the
* task itself. Split out of {@link classifyParkedNegotiation} so a caller that
* ALREADY holds the task — the negotiation listing does — asks the same
* question without re-reading it. One predicate, several callers; never the
* same rule written twice.
*
* Callers must only reach here for `state === "input_required"`.
*/
export function classifyInflightPark(
task: ParkClassificationTask,
input: { opportunityId: string; userId: string },
): Extract<ParkClassification, { kind: "inflight" }> | { kind: "not_parked" } | { kind: "wrong_recipient" } {
const binding = readAskUserResumeBinding(task.metadata);
if (
!binding
|| binding.opportunityId !== input.opportunityId
|| binding.settlementId !== negotiationQuestionSettlementId(task.id)
) {
answerLog.warn("input_required negotiation task carries no coherent ask-user binding; answer cannot resume it", {
taskId: task.id,
opportunityId: input.opportunityId,
});
return { kind: "not_parked" };
}
if (binding.recipientUserId !== input.userId) return { kind: "wrong_recipient" };
return { kind: "inflight", taskId: task.id, binding };
}

/**
* The park a `completed` negotiation task holds, classified from the
* negotiation's own messages. Same split, same reason as
* {@link classifyInflightPark}: the listing has already read these messages.
*
* Callers must only reach here for `state === "completed"`.
*/
export function classifyPostStallPark(
task: Pick<ParkClassificationTask, "id">,
messages: ParkClassificationMessage[],
input: { userId: string },
): Extract<ParkClassification, { kind: "post_stall" }> | { kind: "not_parked" } | { kind: "wrong_recipient" } {
const park = trailingParkMessage(messages);
if (!park) return { kind: "not_parked" };
// The gap was written by the finalizing session's task — the most recent
// one. A trailing park from an older task means state has moved on.
if (park.taskId != null && park.taskId !== task.id) return { kind: "not_parked" };
if (park.senderId !== `agent:${input.userId}`) return { kind: "wrong_recipient" };
return { kind: "post_stall", taskId: task.id };
}

/**
* Re-resolve a negotiation ref to its current park. This is the exact task
* re-resolution the graph itself uses (`getNegotiationTaskForOpportunity`),
* never a snapshot: answer routing branches on what the negotiation is NOW,
* so a park that was answered, expired, or superseded since the block was
* authored classifies as `not_parked` and the answer no-ops.
*
* The two live-park branches are {@link classifyInflightPark} and
* {@link classifyPostStallPark}; this function is the reading half around
* them, and the messages read stays lazy — only a `completed` task pays it.
*/
export async function classifyParkedNegotiation(
database: Pick<NegotiationGraphDatabase, "getNegotiationTaskForOpportunity" | "getNegotiationMessages">,
Expand All @@ -155,32 +223,10 @@ export async function classifyParkedNegotiation(
const task = await database.getNegotiationTaskForOpportunity(input.opportunityId);
if (!task) return { kind: "no_negotiation" };

if (task.state === "input_required") {
const binding = readAskUserResumeBinding(task.metadata);
if (
!binding
|| binding.opportunityId !== input.opportunityId
|| binding.settlementId !== negotiationQuestionSettlementId(task.id)
) {
answerLog.warn("input_required negotiation task carries no coherent ask-user binding; answer cannot resume it", {
taskId: task.id,
opportunityId: input.opportunityId,
});
return { kind: "not_parked" };
}
if (binding.recipientUserId !== input.userId) return { kind: "wrong_recipient" };
return { kind: "inflight", taskId: task.id, binding };
}
if (task.state === "input_required") return classifyInflightPark(task, input);

if (task.state === "completed") {
const messages = await database.getNegotiationMessages(input.opportunityId);
const park = trailingParkMessage(messages);
if (!park) return { kind: "not_parked" };
// The gap was written by the finalizing session's task — the most recent
// one. A trailing park from an older task means state has moved on.
if (park.taskId != null && park.taskId !== task.id) return { kind: "not_parked" };
if (park.senderId !== `agent:${input.userId}`) return { kind: "wrong_recipient" };
return { kind: "post_stall", taskId: task.id };
return classifyPostStallPark(task, await database.getNegotiationMessages(input.opportunityId), input);
}

// submitted/working/waiting_for_agent: a session is live (possibly the very
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,56 @@ type NegotiationConnectionState =
| 'expired'
| 'agents_negotiating'
| 'latent'
/** Parked on THIS user: the negotiation is waiting on their answer. */
| 'parked_awaiting_your_answer'
/** Parked on the other side: nothing here is waiting on this user. */
| 'parked_awaiting_counterparty'
| 'unknown';

/**
* A live park on a listed negotiation, as the listing must narrate it (#1472).
*
* `question`/`questionLabel` are present only for a park on THIS user's side
* whose question the host could name, and the number is the one the
* open-questions prompt section shows and `answer_pending_question` takes —
* they come from the same record, never from a second enumeration. A park on
* the counterparty's side carries no question content: it is not this client's
* to read.
*/
export interface NegotiationParkNarration {
waitingOn: 'you' | 'counterparty';
kind: 'mid_flight' | 'post_stall';
question?: number;
questionLabel?: string;
}

export interface NegotiationLifecycleNarration {
agentNegotiation: 'concluded' | 'in_progress' | 'awaiting_agent' | 'unknown';
opportunityStatus: OpportunityStatus | null;
connectionState: NegotiationConnectionState;
ownerAction: 'accepted' | 'not_recorded';
directConversationEvidence: typeof DIRECT_CONVERSATION_EVIDENCE_UNAVAILABLE;
lifecycleLabel: string;
/** Present only while the negotiation actually holds a park. */
park?: NegotiationParkNarration;
}

/**
* The one line a park is allowed to render as. Opportunity status is the wrong
* question here — a parked pairing is legitimately `negotiating` — so the park
* SUPERSEDES the status label rather than sitting beside it: the persona is
* told to take `lifecycleLabel` as its user-facing wording, and "the agents
* are still negotiating" is exactly the sentence that became a false "nothing
* for you to decide".
*/
export function parkLifecycleLabel(park: NegotiationParkNarration): string {
if (park.waitingOn === 'counterparty') {
return 'PARKED — waiting on the counterparty’s side. Nothing on this pairing is waiting on the client, and the agents are not exchanging turns until the other side answers.';
}
const named = park.question !== undefined && park.questionLabel
? ` waiting on YOUR client’s answer to open question ${park.question}, “${park.questionLabel}”`
: " waiting on YOUR client’s answer";
return `PARKED —${named}. The agents are NOT exchanging turns and will not until the client answers; this is something for them to decide.`;
}

/**
Expand All @@ -31,6 +72,7 @@ export interface NegotiationLifecycleNarration {
export function buildLifecycleNarration(
negotiationStatus: string,
opportunity?: NegotiationOpportunityLifecycle,
park?: NegotiationParkNarration,
): NegotiationLifecycleNarration {
const agentNegotiation: NegotiationLifecycleNarration['agentNegotiation'] = negotiationStatus === 'completed'
? 'concluded'
Expand All @@ -46,6 +88,21 @@ export function buildLifecycleNarration(
directConversationEvidence: DIRECT_CONVERSATION_EVIDENCE_UNAVAILABLE,
};

// A live park outranks the opportunity status for narration. The status is
// still reported in `opportunityStatus`; what it may not do is supply the
// sentence, because `negotiating` is a true status and "still negotiating"
// is a false answer to "is anything waiting on me?".
if (park) {
return {
...common,
connectionState: park.waitingOn === 'you'
? 'parked_awaiting_your_answer'
: 'parked_awaiting_counterparty',
lifecycleLabel: parkLifecycleLabel(park),
park,
};
}

switch (opportunity?.status) {
case 'pending':
return {
Expand Down
6 changes: 6 additions & 0 deletions packages/protocol/src/negotiations/negotiation.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* Anything outside this capability imports from here and nowhere else.
*/
export { AMBIENT_PARK_WINDOW_MS, createNegotiationTools } from "./negotiation.tools.js";
export { buildLifecycleNarration, parkLifecycleLabel } from "./negotiation.lifecycle-narration.js";
export type { NegotiationLifecycleNarration, NegotiationParkNarration } from "./negotiation.lifecycle-narration.js";
export { buildFallbackDigest, NegotiationSummarizer } from "./negotiation.summarizer.js";
export { IndexNegotiator } from "./negotiation.agent.js";
export { negotiateCandidates, NegotiationGraphFactory } from "./negotiation.graph.js";
Expand Down Expand Up @@ -61,7 +63,9 @@ export type {
export { NEGOTIATION_PARK_REASONING, NegotiationStallGapAuthor } from "./negotiation.stall-gap.js";
export type { NegotiationStallGap, NegotiationStallReason, StallGapAuthorInput } from "./negotiation.stall-gap.js";
export {
classifyInflightPark,
classifyParkedNegotiation,
classifyPostStallPark,
consumeQuestionBlockAnswers,
negotiationParkAnswerId,
resumeParkedNegotiation,
Expand All @@ -75,6 +79,8 @@ export type {
NegotiationAnswerInput,
NegotiationAnswerResumeOutcome,
ParkClassification,
ParkClassificationMessage,
ParkClassificationTask,
QuestionBlockAnswerConsumptionInput,
QuestionBlockAnswerConsumptionResult,
RoutedAnswer,
Expand Down
Loading
Loading