golden-kog-risk-debate / tree / src/debate.ts ยท raw (this view has old GitHub links rewritten for the mirror; raw is byte-exact)
// Golden Kog risk debate: two advocates argue a token's structure from measured facts, a reviewer weighs them.
// A risk tool, never a trading tool. Pattern adapted from TradingAgents (TauricResearch, Apache 2.0),
// without its trader or portfolio manager.
import { ADVOCATE_MAX_WORDS, REVIEWER_MAX_WORDS, problemsWith } from './rules.js';

export type Role = 'sound' | 'fragile' | 'reviewer';
export type Mode = 'off' | 'shadow' | 'on';

export interface DebateInput {
  facts_text: string;
  level: string;
  points: number;
  flag_ids: string[];
  unknown: string[];
}

export interface RouteArgs {
  task_type: 'risk_debate';
  tier_class: 'text';
  complexity: 'exec' | 'apex';
  context_tokens: number;
  constitutional_flag: false;
  agent_id: 'golden-kog';
  payload: { role: Role; system: string; prompt: string; max_tokens: number };
}

export interface RouteResult {
  content: string;
  model?: string;
  cost_usd?: number;
}

export interface DebateDeps {
  route(args: RouteArgs): Promise<RouteResult>;
  check?(text: string): string[];
  mode?: Mode;
  log?(row: object): void;
  timeoutMs?: number;
}

export interface Debate {
  status: 'ok' | 'rejected' | 'timeout' | 'off';
  shown: boolean;
  sound: string;
  fragile: string;
  reviewer: string;
  weight: string[];
  unknown: string[];
  tension?: string;
  models: { sound?: string; fragile?: string; reviewer?: string };
  usd: number;
  rejected: string[];
}

export const NO_CASE = 'No case could be written from the measured facts.';
export const DEFAULT_TIMEOUT_MS = 45000;
const ADVOCATE_MAX_TOKENS = 400;
const REVIEWER_MAX_TOKENS = 450;

const RULES_TEXT = [
  'Use only the measured facts given. Every number you write must appear in the facts exactly as written there.',
  'This is a risk analysis of the token structure, never a trading view: no buy, sell or hold view, no price target or price prediction, no entry or exit, no trade instruction, no advice.',
  'Never use these words: bull, bear, bullish, bearish, upside, downside target, undervalued, overvalued, accumulate, moon, buy now, sell now, price target, entry, exit, take profit, not financial advice.',
  'Use Latin digits only and no dash characters of any kind.',
].join(' ');

export const SYSTEM = {
  sound: `You are the advocate for a sound structure. Argue, from the measured facts only, why this token's structure holds up. ${RULES_TEXT} At most ${ADVOCATE_MAX_WORDS} words of plain prose.`,
  fragile: `You are the advocate for a fragile structure. Argue, from the measured facts only, why this token's structure could fail. ${RULES_TEXT} At most ${ADVOCATE_MAX_WORDS} words of plain prose.`,
  reviewer: `You are the reviewer. Weigh the two advocates against the measured facts and say which points carry the most weight and what stays unknown. ${RULES_TEXT} Answer only with JSON: {"text": string of at most ${REVIEWER_MAX_WORDS} words, "weight": array of the risk flag ids that carry the most weight, "unknown": array of short strings, "tension": optional string naming the main disagreement}.`,
} as const;

const tokensOf = (...parts: string[]) => Math.ceil(parts.reduce((n, p) => n + p.length, 0) / 4);

class Timeout extends Error {}

function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
  let timer: ReturnType<typeof setTimeout> | undefined;
  const t = new Promise<never>((_, reject) => {
    timer = setTimeout(() => reject(new Timeout(`call over ${ms} ms`)), ms);
  });
  return Promise.race([p, t]).finally(() => clearTimeout(timer));
}

function args(role: Role, prompt: string): RouteArgs {
  const system = SYSTEM[role];
  return {
    task_type: 'risk_debate',
    tier_class: 'text',
    complexity: role === 'reviewer' ? 'apex' : 'exec',
    context_tokens: tokensOf(system, prompt),
    constitutional_flag: false,
    agent_id: 'golden-kog',
    payload: { role, system, prompt, max_tokens: role === 'reviewer' ? REVIEWER_MAX_TOKENS : ADVOCATE_MAX_TOKENS },
  };
}

function factsPrompt(input: DebateInput): string {
  return [
    'Measured facts, one per line:',
    input.facts_text,
    '',
    `Risk flags that fired: ${input.flag_ids.length ? input.flag_ids.join(', ') : 'none'}`,
    `Known unknowns: ${input.unknown.length ? input.unknown.join('; ') : 'none'}`,
  ].join('\n');
}

// Accepts plain JSON or JSON inside one code fence.
function parseReviewer(content: string): { text: string; weight: string[]; unknown: string[]; tension?: string } | null {
  const fenced = content.trim().match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
  let v: unknown;
  try {
    v = JSON.parse(fenced ? fenced[1] : content);
  } catch {
    return null;
  }
  if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
  const o = v as Record<string, unknown>;
  const strings = (x: unknown) => Array.isArray(x) && x.every((s) => typeof s === 'string');
  if (typeof o.text !== 'string' || !strings(o.weight) || !strings(o.unknown)) return null;
  if (o.tension !== undefined && typeof o.tension !== 'string') return null;
  return { text: o.text, weight: o.weight as string[], unknown: o.unknown as string[], tension: o.tension as string | undefined };
}

function blank(status: Debate['status'], shown: boolean): Debate {
  return { status, shown, sound: '', fragile: '', reviewer: '', weight: [], unknown: [], models: {}, usd: 0, rejected: [] };
}

export async function debate(input: DebateInput, deps: DebateDeps): Promise<Debate> {
  const mode: Mode = deps.mode ?? 'shadow';
  if (mode === 'off') return blank('off', false);
  const shown = mode === 'on';
  const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
  const out = blank('ok', shown);
  const facts = input.facts_text ?? '';
  const finish = (d: Debate) => {
    d.usd = Math.round(d.usd * 1e9) / 1e9; // no float noise in the cost
    if (deps.log) {
      try {
        deps.log({ kind: 'risk_debate', mode, ...d });
      } catch {
        // a failing logger never breaks the debate
      }
    }
    return d;
  };
  const call = async (role: Role, prompt: string) => {
    const r = await withTimeout(deps.route(args(role, prompt)), timeoutMs);
    if (typeof r?.cost_usd === 'number' && Number.isFinite(r.cost_usd)) out.usd += r.cost_usd;
    if (typeof r?.model === 'string') out.models[role] = r.model;
    return typeof r?.content === 'string' ? r.content : '';
  };
  const timedOut = () => {
    const d = blank('timeout', shown);
    d.usd = out.usd;
    d.models = out.models;
    d.rejected = [`a call took longer than ${timeoutMs} ms`];
    return finish(d);
  };

  // 1. The two advocates, in parallel.
  const base = factsPrompt(input);
  let texts: [string, string];
  try {
    const settled = await Promise.allSettled([
      call('sound', `${base}\n\nWrite the case for a sound structure.`),
      call('fragile', `${base}\n\nWrite the case for a fragile structure.`),
    ]);
    if (settled.some((s) => s.status === 'rejected' && s.reason instanceof Timeout)) return timedOut();
    texts = settled.map((s, i) => {
      if (s.status === 'fulfilled') return s.value.trim();
      out.rejected.push(`${i === 0 ? 'sound' : 'fragile'}: call failed: ${s.reason instanceof Error ? s.reason.message : String(s.reason)}`);
      return '';
    }) as [string, string];
  } catch {
    return timedOut();
  }
  for (const [i, role] of (['sound', 'fragile'] as const).entries()) {
    const text = texts[i];
    const problems = text ? problemsWith(role, text, { factsText: facts, maxWords: ADVOCATE_MAX_WORDS, check: deps.check }) : out.rejected.some((r) => r.startsWith(role)) ? [] : [`${role}: empty text`];
    if (!text || problems.length) {
      out.rejected.push(...problems);
      out[role] = NO_CASE;
    } else {
      out[role] = text;
    }
  }

  // 2. The reviewer, with both advocate texts and the facts.
  let content: string;
  try {
    content = await call('reviewer', `${base}\n\nCase for a sound structure:\n${out.sound}\n\nCase for a fragile structure:\n${out.fragile}\n\nWeigh them.`);
  } catch (e) {
    if (e instanceof Timeout) return timedOut();
    return finish(rejectedDebate(out, [`reviewer: call failed: ${e instanceof Error ? e.message : String(e)}`]));
  }
  const parsed = parseReviewer(content);
  if (!parsed) return finish(rejectedDebate(out, ['reviewer: the answer is not the expected JSON']));
  const opts = { factsText: facts, maxWords: REVIEWER_MAX_WORDS, check: deps.check };
  const problems = [
    ...problemsWith('reviewer', parsed.text, opts),
    ...(parsed.tension !== undefined ? problemsWith('reviewer tension', parsed.tension, opts) : []),
  ];
  if (problems.length) return finish(rejectedDebate(out, problems));
  out.reviewer = parsed.text.trim();
  const flags = new Set(input.flag_ids);
  out.weight = [...new Set(parsed.weight.filter((id) => flags.has(id)))];
  out.unknown = [...new Set(parsed.unknown.map((s) => s.trim()).filter((s) => s && problemsWith('unknown', s, { factsText: facts, maxWords: 40, check: deps.check }).length === 0))];
  if (parsed.tension !== undefined && parsed.tension.trim()) out.tension = parsed.tension.trim();
  return finish(out);
}

function rejectedDebate(out: Debate, reasons: string[]): Debate {
  return { ...blank('rejected', out.shown), models: out.models, usd: out.usd, rejected: [...out.rejected, ...reasons] };
}

agentgate-source: code mirror on Cloudflare Pages. All repos