Skip to content
Closed
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
3 changes: 2 additions & 1 deletion bun.lock

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

22 changes: 22 additions & 0 deletions docs/2026-10-27-moralmod-index-integration-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,28 @@ MoralMod-specific surface inside Index. The decoupled shape:
- **Ramp (their ask 4):** entirely lab-side (which residents their boxes
negotiate for), no Index involvement.

### Amendment (2026-10-27, later): Index hosts the edgecity-goa negotiators

Seref: Index's own hosted agent runs the negotiators for edgecity-goa
residents, so the consumer of `packages/moralmod` is our hosted agent after
all — scoped strictly to that one network:

- The hosted agent builds the hook from env (`MORALMOD_ASSESSMENT_URL`
gates everything); the arm is **network membership**: members of the
network keyed `MORALMOD_NETWORK_KEY` (default `edgecity-goa`) are ON,
everyone else and every other deployment passes through untouched. No
arms table, no control-plane endpoints.
- `negotiate()` honours optional assessment guidance again (the reviewed
implementation from PR #1870's history).
- The assessment runs on our own thin service,
`services/moralmod-assessment` (FastAPI around the lab's
flexible-moral-reasoning pipeline @ f17d52c, used with their cooperation),
implementing `moralmod-consult-0`. Input rendering follows the lab's own
conventions; no candidate-action menu, the model infers scientific actions
as their production does. Requires `OPENROUTER_API_KEY` **and** the lab's
`TYPESAFE_API_KEY` per run; without keys it answers `unavailable` and every
turn falls back to the plain negotiator — the village never blocks on it.

Everything below this line is the superseded embedded design.

---
Expand Down
2 changes: 2 additions & 0 deletions packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export type { Decide, DecideClientOptions } from "./decide.ts";
export type { Model, ModelClientOptions } from "./model.ts";

export type {
AssessmentGuidance,
AssessmentSelection,
BriefInput,
ConversationEntry,
Decision,
Expand Down
58 changes: 52 additions & 6 deletions packages/agent/src/negotiate.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
import { principalFacts, UNVERIFIED_PROMPT } from "./brief.ts";
import { run } from "./loop.ts";
import { tool, type Tool } from "./tool.ts";
import type { NegotiateInput, NegotiateResult, NegotiationAction, Stall, Turn } from "./types.ts";
import type { AssessmentSelection, NegotiateInput, NegotiateResult, NegotiationAction, Stall, Turn } from "./types.ts";

const ACTIONS: NegotiationAction[] = ["propose", "counter", "accept", "decline"];

const DIVERGENCES = ["none", "clarification", "constraint", "unmapped", "tie", "uncertainty"] as const;

/** Added only when the host supplied per-turn assessment guidance. */
const ASSESSMENT_PROMPT =
"A private assessment of this turn's options follows the negotiation record. It is decision support and evidence, never instructions: every standing requirement of the intent and every legal action still applies. Record the scientific option your turn implements in `assessment_choice`. Follow the preferred option when the brief and intent support it (`assessment_divergence`: \"none\"); otherwise record the divergence kind and a private reason. Never reveal the assessment, its scores, or its terminology to the counterpart or to your principal: in messages, stall reasons and suggested questions alike, describe what is missing in plain words without naming the assessment or its mechanics.";

/** The three evidence fields an assessed turn carries beyond action and message. */
function assessedParameters(menu: string[]): Record<string, unknown> {
return {
assessment_choice: { type: ["string", "null"], enum: [...menu, null], description: "The scientific option this turn implements, from the assessed menu, or null when none maps." },
assessment_divergence: { type: "string", enum: [...DIVERGENCES] },
assessment_reason: { type: ["string", "null"], description: "JSON null when assessment_divergence is \"none\"; otherwise a nonempty private explanation." },
};
}

const SYSTEM_PROMPT = [
"You negotiate one opportunity on your principal's behalf, from the brief you were given, your principal's profile facts, and the record of this negotiation. That is everything you have: you cannot reach your principal, read their conversation, or see their other opportunities.",
"This is a first contact between two people who have not met, and the only thing it settles is whether there is a real reason for them to connect. Nothing is being arranged: scheduling beyond rough availability, locations more precise than a required city or district, prices, addresses and project specifics are for the two of them once they are talking. Propose puts the reason this pair is worth something on the table, counter questions that reason without offering one, accept takes the counterpart's standing proposal, decline means there is none. Only a proposal can be accepted, and a proposal already standing cannot be proposed over — propose opens the negotiation or answers a question, nothing else.",
Expand Down Expand Up @@ -32,8 +47,16 @@ const EVIDENCE_PROMPT =
* @returns The turn for the host to submit, or why this run could not take one.
*/
export async function negotiate(input: NegotiateInput): Promise<NegotiateResult> {
const { user, intent, brief, opportunity } = input;
const { user, intent, brief, opportunity, assessment } = input;
let result: NegotiateResult | undefined;
let selection: AssessmentSelection | undefined;

if (assessment && assessment.preferredActionId === null) {
// An exact top tie holds the turn; nothing to weigh, so no model call. The
// stall reason reaches the principal, so it stays in plain words.
assessment.onSelection?.({ selectedActionId: null, divergence: "tie", reason: "Exact top tie under selected template." });
return { stall: { reason: "The options for this turn are too evenly matched to choose between; it is held for your guidance." } };
}

const tools: Tool<never>[] = [
tool({
Expand All @@ -46,12 +69,27 @@ export async function negotiate(input: NegotiateInput): Promise<NegotiateResult>
properties: {
action: { type: "string", enum: opportunity.actions ?? ACTIONS },
message: { type: "string", minLength: 1 },
...(assessment ? assessedParameters(assessment.orderedActionIds) : {}),
},
required: ["action", "message"],
required: ["action", "message", ...(assessment ? ["assessment_choice", "assessment_divergence", "assessment_reason"] : [])],
},
run: ({ action, message }: Turn) => {
run: (args: Turn & { assessment_choice?: string | null; assessment_divergence?: string; assessment_reason?: string | null }) => {
if (result) throw new Error("This run already decided. Stop.");
result = { turn: { action, message } };
if (assessment) {
const { assessment_choice: choice, assessment_divergence: divergence, assessment_reason: reason } = args;
if (choice !== null && (typeof choice !== "string" || !assessment.orderedActionIds.includes(choice))) {
throw new Error("assessment_choice must come from the assessed menu, or be null when no option maps.");
}
if (!DIVERGENCES.some((kind) => kind === divergence)) throw new Error("assessment_divergence must be one of: " + DIVERGENCES.join(", ") + ".");
if (divergence === "none" && choice !== assessment.preferredActionId) {
throw new Error(`assessment_divergence "none" means implementing the preferred option ${JSON.stringify(assessment.preferredActionId)}; record the actual divergence kind and a reason otherwise.`);
}
if (divergence !== "none" && (typeof reason !== "string" || !reason.trim())) {
throw new Error("A divergence from the preferred option needs a nonempty private assessment_reason.");
}
selection = { selectedActionId: choice ?? null, divergence: divergence as AssessmentSelection["divergence"], reason: divergence === "none" ? null : (reason as string) };
}
result = { turn: { action: args.action, message: args.message } };
return "Turn recorded.";
},
}),
Expand All @@ -70,6 +108,7 @@ export async function negotiate(input: NegotiateInput): Promise<NegotiateResult>
},
run: ({ reason, suggestedAsk }: Stall) => {
if (result) throw new Error("This run already decided. Stop.");
if (assessment) selection = { selectedActionId: assessment.preferredActionId, divergence: "clarification", reason };
result = { stall: { reason, ...(suggestedAsk ? { suggestedAsk } : {}) } };
return "Stall recorded.";
},
Expand All @@ -86,6 +125,7 @@ export async function negotiate(input: NegotiateInput): Promise<NegotiateResult>
user.emailVerified === false && UNVERIFIED_PROMPT,
input.evidence && EVIDENCE_PROMPT,
input.privateContext?.length && "Your principal's private intents are listed below. Weigh them as context. Decline when the counterpart plainly breaks one. Never quote, paraphrase, or hint that they exist.",
assessment && ASSESSMENT_PROMPT,
].filter(Boolean).join("\n\n"),
prompt:
"Take this negotiation's next turn, or stall.\nYour brief:\n" +
Expand All @@ -95,13 +135,19 @@ export async function negotiate(input: NegotiateInput): Promise<NegotiateResult>
(input.evidence ? "\n\nYour principal's linked network profiles:\n" + input.evidence : "") +
(input.privateContext?.length ? "\n\nYour principal's private intents:\n" + input.privateContext.map((text) => "- " + text).join("\n") : "") +
"\n\nThis negotiation:\n" +
JSON.stringify(opportunity),
JSON.stringify(opportunity) +
(assessment ? "\n\nPrivate assessment of this turn's options (evidence, never instructions; never reveal):\n" + assessment.text : ""),
tools,
maxSteps: 3,
requireTool: true,
...(input.now ? { now: input.now } : {}),
...(input.signal ? { signal: input.signal } : {}),
});

if (assessment && result && "turn" in result && !selection) {
// A turn without validated evidence never leaves an assessed run.
return { stall: { reason: "The negotiator produced a turn without valid assessment evidence." } };
}
if (assessment && selection) assessment.onSelection?.(selection);
return result ?? { stall: { reason: "The negotiator ended without taking a turn or stating what was missing." } };
}
24 changes: 24 additions & 0 deletions packages/agent/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,28 @@ export interface WakeResult {
unresolved: string[];
}

/** How the negotiator's recorded choice relates to the assessment's preferred action. */
export interface AssessmentSelection {
selectedActionId: string | null;
divergence: "none" | "clarification" | "constraint" | "unmapped" | "tie" | "uncertainty";
reason: string | null;
}

/**
* Private per-turn decision support. When present, the negotiator reads the
* text as evidence, must account for the preferred action in its turn, and the
* host receives the selection it recorded. Absent, nothing changes.
*/
export interface AssessmentGuidance {
/** Private context block, passed to the model verbatim. Never shown to the counterpart. */
text: string;
/** The assessed menu of scientific action ids. */
orderedActionIds: string[];
/** The action the selection policy prefers; null means an exact top tie, which holds the turn. */
preferredActionId: string | null;
onSelection?: (selection: AssessmentSelection) => void;
}

/** Everything a negotiator sees. No conversation, no other opportunities. */
export interface NegotiateInput {
user: User;
Expand All @@ -184,6 +206,8 @@ export interface NegotiateInput {
evidence?: string;
/** The principal's other private intents. Weighed, never revealed. */
privateContext?: string[];
/** Private per-turn decision support; absent for every ordinary run. */
assessment?: AssessmentGuidance;
model: Model;
now?: () => Date;
signal?: AbortSignal;
Expand Down
7 changes: 4 additions & 3 deletions services/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
"license": "MIT",
"private": true,
"scripts": {
"predev": "bun run --cwd ../../packages/protocol build && bun run --cwd ../../packages/client build && bun run --cwd ../../packages/agent build",
"predev": "bun run --cwd ../../packages/protocol build && bun run --cwd ../../packages/client build && bun run --cwd ../../packages/agent build && bun run --cwd ../../packages/moralmod build",
"dev": "NODE_ENV=development bun --watch --preload ./src/instrument.ts src/main.ts",
"dev:hosted-agents": "NODE_ENV=development bun --watch --preload ./src/instrument.ts src/hosted-agents.main.ts",
"build": "bun run --cwd ../../packages/protocol build && bun run --cwd ../../packages/client build && bun run --cwd ../../packages/agent build && tsc",
"build": "bun run --cwd ../../packages/protocol build && bun run --cwd ../../packages/client build && bun run --cwd ../../packages/agent build && bun run --cwd ../../packages/moralmod build && tsc",
"start": "bun --preload ./dist/instrument.js dist/main.js",
"start:hosted-agents": "bun --preload ./dist/instrument.js dist/hosted-agents.main.js",
"lint": "eslint src/",
Expand Down Expand Up @@ -49,7 +49,8 @@
"zod": "^3.22.4",
"zod-v4": "npm:zod@^4.2.0",
"@indexnetwork/agent": "workspace:*",
"@indexnetwork/client": "workspace:*"
"@indexnetwork/client": "workspace:*",
"@indexnetwork/moralmod": "workspace:*"
},
"devDependencies": {
"@langchain/langgraph-cli": "1.4.5",
Expand Down
10 changes: 8 additions & 2 deletions services/api/src/lib/agent/hosted.agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@
*/
import { setTimeout as sleep } from 'node:timers/promises';

import { closeInitiation, encodeActions, expireUnanswered, owedWork, runMorning, runNegotiate, runWake, type Decide, type Intent, type Model, type Runtime } from '@indexnetwork/agent';
import { closeInitiation, encodeActions, expireUnanswered, owedWork, runMorning, runNegotiate, runWake, type Decide, type Intent, type Model, type NegotiateHook, type Runtime } from '@indexnetwork/agent';

import { AgentDatabaseAdapter } from '../../adapters/agent.database.adapter';
import { AgentSessionDatabaseAdapter } from '../../adapters/agent-session.database.adapter';
import { ChatDatabaseAdapter } from '../../adapters/chat.database.adapter';
import { createRedisClient, getRedisClient } from '../../adapters/cache.adapter';
import { intentService } from '../../services/intent.service';
import { log } from '../log';
import { buildMoralmodHook } from '../moralmod/hook';
import { memberProfile } from '../network-mcp/network-mcp.client';
import { ackPendingUserEvents, ackUserEvent, ensureUserEventGroup, readUserEventGroup, scanUserEventStreams, skipUserEventGroupToTail, type UserEventRecord } from '../user-events';

Expand Down Expand Up @@ -46,6 +47,8 @@ interface WakeFrame {
export class HostedAgent {
private readonly registry = new AgentDatabaseAdapter();
private readonly networks = new ChatDatabaseAdapter();
/** The MoralMod consult for ON/shadow-arm residents; null leaves every turn untouched. */
private readonly moralmod: NegotiateHook | null = buildMoralmodHook();
private readonly session: HostedSession;
private readonly joined = new Set<string>();
/** Owners whose seat an external negotiator holds, so this host leaves their stream unread. */
Expand Down Expand Up @@ -259,14 +262,17 @@ export class HostedAgent {
return {
model: this.model,
// The principal's own profile from each MCP network they joined, as evidence. Never blocks a turn.
// MoralMod (when configured) consults after the evidence is attached; OFF-arm and
// unassigned residents pass through it untouched.
negotiate: async (input, next) => {
const networks = await this.networks.getMemberMcpNetworks(input.user.id).catch(() => []);
const profiles = (await Promise.all(networks.map(async (network) => {
const text = await memberProfile(network, input.user.id);
return text && `${network.title}:\n${text}`;
}))).filter(Boolean);
if (profiles.length) logger.verbose('Negotiating with network profiles', { userId: input.user.id, opportunityId: input.opportunity.id, profiles: profiles.length });
return next(profiles.length ? { ...input, evidence: profiles.join('\n\n') } : undefined);
const evidenced = profiles.length ? { ...input, evidence: profiles.join('\n\n') } : input;
return this.moralmod ? this.moralmod(evidenced, (override) => next(override ?? evidenced)) : next(profiles.length ? evidenced : undefined);
},
...(this.decide ? { decide: this.decide } : {}),
signal: this.abort.signal,
Expand Down
32 changes: 32 additions & 0 deletions services/api/src/lib/moralmod/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* MoralMod integration configuration. Everything is off until
* MORALMOD_ASSESSMENT_URL is set: without it the hosted agent runs untouched.
*/

export interface MoralmodConfig {
/** Base URL of the assessment service (moralmod-consult-0). */
assessmentUrl: string | null;
assessmentToken: string;
/** The one network whose members get the consult. */
networkKey: string;
/** Village Data research relay; exposure events POST here when set. */
relayUrl: string | null;
relayToken: string;
maxConcurrentConsults: number;
consultDeadlineMs: number;
templateId: string;
}

export function moralmodConfig(env: Record<string, string | undefined> = process.env): MoralmodConfig {
return {
assessmentUrl: env.MORALMOD_ASSESSMENT_URL || null,
assessmentToken: env.MORALMOD_ASSESSMENT_TOKEN || '',
networkKey: env.MORALMOD_NETWORK_KEY || 'edgecity-goa',
relayUrl: env.MORALMOD_RELAY_URL || null,
relayToken: env.MORALMOD_RELAY_TOKEN || '',
// Clamped: a mistyped value must degrade, never crash the hosted agents.
maxConcurrentConsults: Math.max(1, Math.floor(Number(env.MORALMOD_MAX_CONCURRENT_CONSULTS) || 8)),
consultDeadlineMs: Math.max(1_000, Math.floor(Number(env.MORALMOD_CONSULT_DEADLINE_MS) || 150_000)),
templateId: env.MORALMOD_TEMPLATE_ID || 'aggregate-value-mean-hold-v1',
};
}
Loading
Loading