diff --git a/GRUNTCODE.md b/GRUNTCODE.md index d085ffb5cc93..0e1d22ad5182 100644 --- a/GRUNTCODE.md +++ b/GRUNTCODE.md @@ -12,6 +12,7 @@ Current patches (each tracked in [hivemind #222](https://github.com/grunt-it/hiv 2. **Attach subscribes to all session message updates.** When a wake fires via `/session//prompt_async`, the attach-client TUI renders the response. (Without this patch, externally-triggered messages land in the server's db but never reach the user's screen.) 3. **`OPENCODE_SERVER_URL` propagation.** The attach binary reads and propagates this env var to all subprocesses (MCP children, etc.) so agents can find their serve daemon without a launch-script shim. 4. **`--peer-id ` flag.** Lets launch scripts set the tab's hivemind peer-id at startup instead of via TAKEOFF gymnastics. Powers patch #1. +5. **Turn-end hook into the hivemind loop primitive ([#266](https://github.com/grunt-it/hivemind-mcp/issues/266) Phase 1).** Behind feature flag `OPENCODE_HIVEMIND_LOOP_ENABLED=1`. Every step-finish fires `hivemind_record_turn_end` into the hivemind MCP, which evaluates the three-layer goal-loop contract + decides to auto-wake the same peer (`continue`), wake the parent peer (`escalate` / `await-review`), or no-op. Per-tool-call `hivemind_loop_progress` bumps the progress timestamp. Fire-and-forget via `Effect.forkIn(scope)` — hook failures NEVER break the TUI. Opt-in per-tab via env var in Phase 1; planned default-on in Phase 2 after validating in the wild. Everything else is opencode upstream. We rebase against `anomalyco/opencode:dev` weekly. diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index b12a5d5707c4..8aff4ad67d8c 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -54,6 +54,15 @@ export class Service extends ConfigService.Service()("@opencode/Runtime outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"), bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"), experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"), + /** + * Loop primitive (#266 Phase 1) — when true, the session processor fires + * hivemind_record_turn_end after every step-finish + hivemind_loop_progress after every + * tool-result. The MCP then decides whether to auto-wake the same peer (continue), wake + * the parent (escalate), or no-op. Default off in Phase 1; opt-in per-tab via env var; + * planned default-on in Phase 2 after validating in the wild. Failure of the hook is + * always non-fatal — the TUI is never blocked or thrown into on a hivemind error. + */ + hivemindLoopEnabled: bool("OPENCODE_HIVEMIND_LOOP_ENABLED"), client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")), }) {} diff --git a/packages/opencode/src/session/hivemind-loop-hook.ts b/packages/opencode/src/session/hivemind-loop-hook.ts new file mode 100644 index 000000000000..68e63ffadcef --- /dev/null +++ b/packages/opencode/src/session/hivemind-loop-hook.ts @@ -0,0 +1,164 @@ +import { Effect, Option, Scope } from "effect" +import { MCP } from "@/mcp" +import { MessageV2 } from "./message-v2" +import * as Log from "@opencode-ai/core/util/log" + +type MCPShape = MCP.Interface + +/** + * Loop-primitive hook (#266 Phase 1) — bridges gruntcode's per-turn lifecycle to the + * hivemind-mcp loop primitive (hivemind_record_turn_end + hivemind_loop_progress). + * + * The loop primitive shipped in hivemind-mcp v0.8.2 (PR #18) provides the data layer + + * decision logic; THIS module is the event source. Every gruntcode step-finish + every + * tool-result fires into the MCP, which decides whether to continue the same peer (auto- + * wake with continuation prompt), escalate to parent (auto-wake with violation context), + * or no-op (clean exit / rate-limited / no loop_goal set). + * + * Hard correctness rule (#266 spec): hook failures MUST NEVER break the TUI. Every call + * pipes through Effect.ignore + Effect.forkIn(scope) so the TUI never blocks on the MCP + * call and never sees an exception from a slow / failing hivemind connection. + * + * Feature-flagged: opt-in via OPENCODE_HIVEMIND_LOOP_ENABLED=1 (RuntimeFlags.hivemindLoopEnabled). + * Off by default in Phase 1 so users can adopt per-tab + we can flip the global default in + * Phase 2 after validating in the wild. + */ + +const log = Log.create({ service: "session.hivemind-loop-hook" }) + +const EXCERPT_CAP = 500 + +/** + * Find the MCP client that exposes the loop primitive tools. The hivemind MCP is conventionally + * named "hivemind" in user opencode.json but we don't hard-code the name — we look up the + * client by checking each one's tool list for our target tool. + * + * Takes a pre-yielded MCP.Service instance instead of yielding it itself so callers (which + * yield the service once at layer construction) can pass it through without re-yielding. This + * is what lets the hook satisfy the processor's `never` environment requirement. + */ +const findHivemindClient = Effect.fnUntraced(function* (mcp: MCPShape, toolName: string) { + const clients = yield* mcp.clients() + const clientNames = Object.keys(clients) + if (clientNames.length === 0) return undefined + // Convention: the hivemind MCP is named "hivemind". Most users will have it under that key, + // and that's the fast path. We fall through to a scan only if the conventional name isn't + // present — handles the rare case where someone renamed it (e.g. "hivemind-prod") or set up + // multiple coordination MCPs. + if (clientNames.includes("hivemind")) { + return clients["hivemind"] + } + // Slow path: ask each client for its tool list + pick the one that exposes our tool. We + // accept the cost only when the conventional name isn't present, which should be rare. + const tools = yield* mcp.tools() + // The tools map composes "_" as the registry key. Find a key matching + // our tool name suffix, then return that client. + const matchingKey = Object.keys(tools).find((k) => k.endsWith(`_${toolName}`) || k === toolName) + if (!matchingKey) return undefined + const owningName = clientNames.find((name) => matchingKey.startsWith(`${name}_`) || matchingKey === toolName) + return owningName ? clients[owningName] : undefined +}) + +/** + * Build the last-message excerpt + tool-call count for a finished turn. Reads the + * assistant message's parts synchronously via MessageV2.parts (DB read). + * + * Excerpt: concatenate text parts in part-id order, take last 500 chars. Tool count: count + * tool parts whose state.status indicates a completed (non-error, non-pending) call. The + * MCP's evaluateLoop logic uses tool_call_count=0 as a "silent stall" signal, so we want + * the count to reflect actual tool ACTIVITY this turn, not just any tool part. + */ +function buildTurnSummary(messageId: MessageV2.Assistant["id"]) { + const parts = MessageV2.parts(messageId) + const textParts = parts.filter((p): p is MessageV2.TextPart => p.type === "text") + // Concatenate text parts to form the excerpt. Most turns have one or two text parts; join + // with newlines so contiguous text reads naturally. + const fullText = textParts.map((p) => p.text).join("\n").trim() + const excerpt = + fullText.length > EXCERPT_CAP + ? fullText.slice(fullText.length - EXCERPT_CAP) + : fullText + const toolCallCount = parts.filter((p) => p.type === "tool").length + return { excerpt, toolCallCount } +} + +/** + * Fire hivemind_record_turn_end after a step-finish. Caller invokes this inside the + * processor's step-finish handler. Fire-and-forget via Effect.forkIn(scope) so the + * processor's hot path doesn't block on the MCP roundtrip. + * + * The MCP wraps evaluateLoop + fires the wake/escalation internally. This side just sends + * the turn-end event and forgets. + * + * Off when OPENCODE_HIVEMIND_LOOP_ENABLED is unset/false — silent no-op. + */ +export const recordTurnEnd = Effect.fn("HivemindLoopHook.recordTurnEnd")(function* (input: { + enabled: boolean + mcp: Option.Option + sessionID: string + messageID: MessageV2.Assistant["id"] + finishReason: string | undefined + scope: Scope.Scope +}) { + if (!input.enabled) return + if (Option.isNone(input.mcp)) return + const client = yield* findHivemindClient(input.mcp.value, "hivemind_record_turn_end") + if (!client) { + yield* Effect.logDebug("no hivemind MCP client found for record_turn_end; flag enabled but MCP unavailable") + return + } + // Build summary AFTER client resolution so we don't pay the DB read when we're going to + // no-op anyway. + const summary = buildTurnSummary(input.messageID) + // Fork the MCP call into the scope so the processor doesn't await it. Effect.ignore swallows + // any error from the call — the loop primitive's correctness rule is that the hook must + // never break the TUI, so we accept silent best-effort delivery here. + yield* Effect.promise(() => + client.callTool({ + name: "hivemind_record_turn_end", + arguments: { + session_id: input.sessionID, + finish_reason: input.finishReason ?? null, + last_msg_excerpt: summary.excerpt, + tool_call_count: summary.toolCallCount, + }, + }), + ).pipe( + Effect.tapError((err) => + Effect.sync(() => log.warn("recordTurnEnd MCP call failed (non-fatal)", { err })), + ), + Effect.ignore, + Effect.forkIn(input.scope), + ) +}) + +/** + * Fire hivemind_loop_progress after a tool-result. Cheap call — just bumps + * last_loop_progress_at so evaluate_loop sees fresh activity. Same fire-and-forget guarantee. + * + * Off when OPENCODE_HIVEMIND_LOOP_ENABLED is unset/false — silent no-op. + */ +export const loopProgress = Effect.fn("HivemindLoopHook.loopProgress")(function* (input: { + enabled: boolean + mcp: Option.Option + scope: Scope.Scope +}) { + if (!input.enabled) return + if (Option.isNone(input.mcp)) return + const client = yield* findHivemindClient(input.mcp.value, "hivemind_loop_progress") + if (!client) return + yield* Effect.promise(() => + client.callTool({ + name: "hivemind_loop_progress", + arguments: {}, + }), + ).pipe( + Effect.tapError((err) => + Effect.sync(() => log.debug("loopProgress MCP call failed (non-fatal)", { err })), + ), + Effect.ignore, + Effect.forkIn(input.scope), + ) +}) + +export * as HivemindLoopHook from "./hivemind-loop-hook" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index a287c3b00680..e30d1a52c22e 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -27,6 +27,8 @@ import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import * as DateTime from "effect/DateTime" import { RuntimeFlags } from "@/effect/runtime-flags" +import { MCP } from "@/mcp" +import { HivemindLoopHook } from "./hivemind-loop-hook" import { Usage, type LLMEvent } from "@opencode-ai/llm" const DOOM_LOOP_THRESHOLD = 3 @@ -101,6 +103,12 @@ export const layer = Layer.effect( const image = yield* Image.Service const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service + // MCP.Service is yielded as Option (#266 Phase 1 hivemind loop hook). At production + // runtime the prompt layer provides MCP (session/prompt.ts:1649). In test layers MCP is + // typically NOT provided — yielding as Option lets the processor cleanly degrade to + // no-op for the hook when MCP isn't in context, instead of forcing every test layer to + // provide an unused service. + const mcpOption = yield* Effect.serviceOption(MCP.Service) const create = Effect.fn("SessionProcessor.create")(function* (input: Input) { // Pre-capture snapshot before the LLM stream starts. The AI SDK @@ -498,6 +506,14 @@ export const layer = Layer.effect( }) } yield* completeToolCall(value.id, output) + // Hivemind loop primitive (#266 Phase 1): bump last_loop_progress_at after every + // successful tool result. Cheap signal that the peer is actively driving its loop — + // evaluateLoop uses this to distinguish "made progress" from "silent stall". + yield* HivemindLoopHook.loopProgress({ + enabled: flags.hivemindLoopEnabled, + mcp: mcpOption, + scope, + }) return } @@ -607,6 +623,18 @@ export const layer = Layer.effect( messageID: ctx.assistantMessage.parentID, }) .pipe(Effect.ignore, Effect.forkIn(scope)) + // Hivemind loop primitive (#266 Phase 1): fire the turn-end event into hivemind-mcp. + // The MCP runs evaluateLoop + decides auto-wake / auto-escalate / no-op. Off by + // default; opt-in via OPENCODE_HIVEMIND_LOOP_ENABLED. Always fire-and-forget — + // hivemind failure must never break the TUI. + yield* HivemindLoopHook.recordTurnEnd({ + enabled: flags.hivemindLoopEnabled, + mcp: mcpOption, + sessionID: ctx.sessionID, + messageID: ctx.assistantMessage.id, + finishReason: value.reason, + scope, + }) if ( !ctx.assistantMessage.summary && isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model }) diff --git a/packages/opencode/test/session/hivemind-loop-hook.test.ts b/packages/opencode/test/session/hivemind-loop-hook.test.ts new file mode 100644 index 000000000000..f30be5bd8fb5 --- /dev/null +++ b/packages/opencode/test/session/hivemind-loop-hook.test.ts @@ -0,0 +1,125 @@ +import { describe, expect } from "bun:test" +import { Effect, Exit, Layer, Option, Scope } from "effect" +import { HivemindLoopHook } from "@/session/hivemind-loop-hook" +import { MCP } from "@/mcp" +import { testEffect } from "../lib/effect" +import { MessageID } from "@/session/schema" + +/** + * Loop hook unit tests (#266 Phase 1). The hook is intentionally narrow: it gates on a + * feature flag, finds the hivemind MCP client, and fires fire-and-forget Effect.promise + * wrappers. The hard correctness rule is "MUST NEVER break the TUI" — disabled path is a + * silent no-op, missing-MCP path is a silent no-op, and the in-flight wake itself is + * forked via Effect.forkIn(scope) + Effect.ignore so even an MCP exception is swallowed. + * + * These tests cover the gating paths (flag off + MCP-not-provided) without needing a + * full MCP fixture. The wake fire-path is exercised end-to-end via the real coordinator + * dispatch loop in production once Phase 1 is enabled. + */ + +const it = testEffect(Layer.empty) + +describe("HivemindLoopHook.recordTurnEnd", () => { + it.effect("flag off → silent no-op (no MCP needed)", () => + Effect.gen(function* () { + const scope = yield* Scope.make() + // No MCP layer provided + enabled=false. The hook returns immediately on flag check; + // no error, no environment requirement leaks. + yield* HivemindLoopHook.recordTurnEnd({ + enabled: false, + mcp: Option.none(), + sessionID: "ses_x", + messageID: MessageID.make("msg_x"), + finishReason: "stop", + scope, + }) + yield* Scope.close(scope, Exit.succeed(undefined)) + // If we reached here without throwing, the no-op path is clean. + expect(true).toBe(true) + }), + ) + + it.effect("flag on but MCP=None → silent no-op (no error)", () => + Effect.gen(function* () { + const scope = yield* Scope.make() + yield* HivemindLoopHook.recordTurnEnd({ + enabled: true, + mcp: Option.none(), + sessionID: "ses_x", + messageID: MessageID.make("msg_x"), + finishReason: "stop", + scope, + }) + yield* Scope.close(scope, Exit.succeed(undefined)) + expect(true).toBe(true) + }), + ) +}) + +describe("HivemindLoopHook.loopProgress", () => { + it.effect("flag off → silent no-op", () => + Effect.gen(function* () { + const scope = yield* Scope.make() + yield* HivemindLoopHook.loopProgress({ + enabled: false, + mcp: Option.none(), + scope, + }) + yield* Scope.close(scope, Exit.succeed(undefined)) + expect(true).toBe(true) + }), + ) + + it.effect("flag on but MCP=None → silent no-op", () => + Effect.gen(function* () { + const scope = yield* Scope.make() + yield* HivemindLoopHook.loopProgress({ + enabled: true, + mcp: Option.none(), + scope, + }) + yield* Scope.close(scope, Exit.succeed(undefined)) + expect(true).toBe(true) + }), + ) +}) + +describe("HivemindLoopHook with mocked MCP", () => { + it.effect("flag on + MCP Some + no hivemind client → silent no-op (no throw)", () => + Effect.gen(function* () { + const scope = yield* Scope.make() + // Build a minimal MCP service stub: clients() returns empty map → hook can't find + // hivemind, returns silently. Other interface methods aren't called by the hook. + const stubMcp = { + status: () => Effect.succeed({}), + clients: () => Effect.succeed({}), + tools: () => Effect.succeed({}), + prompts: () => Effect.succeed({}), + resources: () => Effect.succeed({}), + add: () => Effect.succeed({ status: {} }), + connect: () => Effect.void, + disconnect: () => Effect.void, + getPrompt: () => Effect.succeed(undefined), + readResource: () => Effect.succeed(undefined), + startAuth: () => Effect.succeed({ authorizationUrl: "", oauthState: "" }), + authenticate: () => Effect.succeed({ status: "connected" as const }), + finishAuth: () => Effect.succeed({ status: "connected" as const }), + removeAuth: () => Effect.void, + supportsOAuth: () => Effect.succeed(false), + hasStoredTokens: () => Effect.succeed(false), + getAuthStatus: () => Effect.succeed({ kind: "none" as const }), + } as unknown as MCP.Interface + + yield* HivemindLoopHook.recordTurnEnd({ + enabled: true, + mcp: Option.some(stubMcp), + sessionID: "ses_x", + messageID: MessageID.make("msg_x"), + finishReason: "stop", + scope, + }) + yield* Scope.close(scope, Exit.succeed(undefined)) + expect(true).toBe(true) + }), + ) +})