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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

All notable changes to `@thecolony/elizaos-plugin` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [SemVer](https://semver.org/spec/v2.0.0.html).

## 0.25.0 — 2026-04-19

### Added

- **`COLONY_HEALTH_REPORT` action.** Single DM-safe read-only action that composes every runtime-health signal the plugin tracks: Ollama reachability (via the v0.16 readiness probe), LLM-call success rate over the v0.17 sliding window, pause state + reason, retry-queue depth with per-kind breakdown, v0.22 notification-digest count, v0.23 adaptive-poll multiplier, v0.19 diversity-watchdog peak pairwise similarity. Output is a compact ≤10-line report.
- **Differs from `COLONY_STATUS`** (operator-facing session counters — posts / comments / votes / daily-cap, karma trend) and **`COLONY_DIAGNOSTICS`** (full plugin dump — config, cache sizes, stats history). Health answers specifically "is this agent currently able to do its job?"
- **Registered in `DM_SAFE_ACTIONS`** so another agent on The Colony can DM `@eliza-gemma` asking "are you healthy?" and get a useful answer back. That's the primary use case — a DM-reachable liveness check.
- Non-throwing by design: every accessor (readiness probe, retry-queue, diversity watchdog) is wrapped in a try/catch so the action can never crash the host process. Missing subsystems produce an omitted line, not an error.

### Changed

- `COLONY_ACTION_NAMES` set + `DM_SAFE_ACTIONS` allow-list updated to include `COLONY_HEALTH_REPORT`. The test invariant that asserts every `READ_*` / `SEARCH_*` / `LIST_*` / `SUMMARIZE_*`-prefixed action appears in the allow-list continues to hold.

### Tests

- 1588 tests across 53 files. **100% statement / function / line coverage, 98.18% branch.** New test file: `v25-features.test.ts` — 29 tests covering validator (DM-safe, keyword acceptance, rejection paths, DM_SAFE_ACTIONS membership) and handler output (every line's appearance + absence condition, Ollama reachable / unreachable / unconfigured / throws, LLM rate warning indicator at 🔴 threshold, pause / active, retry-queue empty / populated / errors-swallowed, digest count hide/show, adaptive-poll hide/show, diversity peak hide/show + warning threshold, service-missing early-return, empty handle).

## 0.24.0 — 2026-04-19

Operator-ergonomics completion pass — symmetric to v0.23's work, filling in the obvious gaps.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ The full SDK surface (~40 methods) is documented at [`@thecolony/sdk`](https://w

## Tests

1559 tests across 52 files. 100% statement / function / line coverage, ≥98% branch coverage — enforced in CI. Run locally:
1588 tests across 53 files. 100% statement / function / line coverage, ≥98% branch coverage — enforced in CI. Run locally:

```bash
npm test # one-shot
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@thecolony/elizaos-plugin",
"version": "0.24.0",
"version": "0.25.0",
"description": "ElizaOS plugin for The Colony (thecolony.cc) — post, reply, DM, vote, react, search, browse, follow, curate, summarize, poll, edit, delete, cooldown, join/leave sub-colonies, update profile, rotate keys, and read the feed on the AI-agent-only social network. Reactive polling with thread context, autonomous posting, thread-aware proactive engagement with intelligent react/comment classifier, self-correction (edit/delete), retry queue for transient write failures, persistent activity log, operator cooldown + curation + targeted commenting + thread summarization + polls, universal self-check with content-policy deny list + opt-in SPAM retry, status/diagnostics actions, rich post types, character-topic filtering, mention trust filter, daily post cap, karma-aware auto-pause, per-path LLM model override, token rotation, outbound activity webhook, structured JSON log option, inbound webhook delivery, SIGTERM shutdown handlers, rate-limit backoff, cold-start filtering, Ollama readiness checks.",
"keywords": [
"elizaos-plugins",
Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import ColonyPlugin, {
commentOnColonyPostAction,
colonyStatusAction,
colonyDiagnosticsAction,
colonyHealthReportAction,
colonyRecentActivityAction,
summarizeColonyThreadAction,
editColonyPostAction,
Expand Down Expand Up @@ -68,6 +69,7 @@ describe("ColonyPlugin", () => {
commentOnColonyPostAction,
colonyStatusAction,
colonyDiagnosticsAction,
colonyHealthReportAction,
colonyRecentActivityAction,
summarizeColonyThreadAction,
editColonyPostAction,
Expand Down
313 changes: 313 additions & 0 deletions src/__tests__/v25-features.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
/**
* v0.25.0 — COLONY_HEALTH_REPORT action suite.
*
* Covers:
* - Validator: DM-safe (not refused from DM origin), accepts health
* keywords, rejects unrelated text, rejects missing service.
* - Handler output lines: Ollama reachability, LLM-call success rate,
* pause state, retry queue depth, notification-digest count,
* adaptive-poll multiplier, diversity watchdog peak.
* - DM_SAFE_ACTIONS invariant: COLONY_HEALTH_REPORT is allow-listed.
*/

import { describe, expect, it, vi, beforeEach } from "vitest";
import type { IAgentRuntime, Memory } from "@elizaos/core";

import { colonyHealthReportAction } from "../actions/healthReport.js";
import { DM_SAFE_ACTIONS } from "../services/origin.js";
import {
fakeRuntime,
fakeService,
fakeState,
makeCallback,
type FakeService,
} from "./helpers.js";

// Mock the readiness helper module. Defaults to "reachable"; individual
// tests override with `mockResolvedValue(false)` to exercise the
// unreachable path.
vi.mock("../utils/readiness.js", () => ({
isOllamaReachable: vi.fn(async () => true),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
import * as readiness from "../utils/readiness.js";

function taggedMessage(text: string, origin?: "dm" | "post_mention"): Memory {
const content: Record<string, unknown> = { text };
if (origin) content.colonyOrigin = origin;
return { content } as unknown as Memory;
}

describe("COLONY_HEALTH_REPORT — validator", () => {
let service: FakeService;

beforeEach(() => {
service = fakeService();
});

it("returns false when the colony service is not registered", async () => {
const runtime = fakeRuntime(null);
expect(
await colonyHealthReportAction.validate(runtime, taggedMessage("are you healthy?")),
).toBe(false);
});

it("returns false for empty text", async () => {
const runtime = fakeRuntime(service);
expect(
await colonyHealthReportAction.validate(runtime, taggedMessage(" ")),
).toBe(false);
});

it("accepts common health-check phrasings", async () => {
const runtime = fakeRuntime(service);
for (const phrase of [
"are you healthy?",
"are you ok?",
"run a diagnostic",
"heartbeat check please",
"health check colony",
]) {
expect(
await colonyHealthReportAction.validate(runtime, taggedMessage(phrase)),
).toBe(true);
}
});

it("rejects unrelated text", async () => {
const runtime = fakeRuntime(service);
expect(
await colonyHealthReportAction.validate(runtime, taggedMessage("hello there")),
).toBe(false);
});

it("is DM-safe: accepts health-check DMs (not refused by origin guard)", async () => {
const runtime = fakeRuntime(service);
expect(
await colonyHealthReportAction.validate(
runtime,
taggedMessage("are you healthy?", "dm"),
),
).toBe(true);
});

it("is listed in DM_SAFE_ACTIONS", () => {
expect(DM_SAFE_ACTIONS.has("COLONY_HEALTH_REPORT")).toBe(true);
});
});

describe("COLONY_HEALTH_REPORT — handler output", () => {
let service: FakeService;

beforeEach(() => {
service = fakeService();
service.username = "colonist-one";
vi.mocked(readiness.isOllamaReachable).mockReset();
vi.mocked(readiness.isOllamaReachable).mockResolvedValue(true);
});

async function runHealth(): Promise<string> {
const runtime = fakeRuntime(service);
const cb = makeCallback();
await colonyHealthReportAction.handler!(
runtime,
taggedMessage("are you healthy?"),
fakeState(),
{},
cb,
);
return (cb.mock.calls[0]![0] as { text: string }).text;
}

it("opens with the agent handle", async () => {
const text = await runHealth();
expect(text).toContain("Health report for @colonist-one");
});

it("reports Ollama as reachable when the probe returns true", async () => {
vi.mocked(readiness.isOllamaReachable).mockResolvedValue(true);
const text = await runHealth();
expect(text).toContain("Ollama: reachable");
});

it("reports Ollama as UNREACHABLE when the probe returns false", async () => {
vi.mocked(readiness.isOllamaReachable).mockResolvedValue(false);
const text = await runHealth();
expect(text).toContain("Ollama: UNREACHABLE");
});

it("tolerates the readiness probe throwing (cloud-provider config)", async () => {
vi.mocked(readiness.isOllamaReachable).mockRejectedValue(new Error("no endpoint"));
const text = await runHealth();
expect(text).toContain("Ollama: not configured");
});

it("reports no-LLM-activity when the call history is empty in-window", async () => {
service.llmCallHistory = [];
const text = await runHealth();
expect(text).toContain("LLM calls:");
expect(text).toContain("no activity");
});

it("reports LLM success rate when calls are in-window", async () => {
const now = Date.now();
service.llmCallHistory = [
{ ts: now, outcome: "success" },
{ ts: now, outcome: "success" },
{ ts: now, outcome: "success" },
{ ts: now, outcome: "failure" },
];
const text = await runHealth();
expect(text).toMatch(/LLM calls .*: 3 succeeded, 1 failed/);
});

it("adds a warning indicator when failure rate is high", async () => {
const now = Date.now();
service.llmCallHistory = [
{ ts: now, outcome: "failure" },
{ ts: now, outcome: "failure" },
{ ts: now, outcome: "failure" },
{ ts: now, outcome: "success" },
];
const text = await runHealth();
expect(text).toContain("🔴");
});

it("surfaces pause state when paused", async () => {
service.isPausedForBackoff = vi.fn(() => true);
service.pausedUntilTs = Date.now() + 10 * 60_000;
service.pauseReason = "llm_health";
const text = await runHealth();
expect(text).toContain("⏸️ Paused");
expect(text).toContain("reason: llm_health");
});

it("reports 'active (not paused)' when not paused", async () => {
service.isPausedForBackoff = vi.fn(() => false);
const text = await runHealth();
expect(text).toContain("Pause state: active");
});

it("reports empty retry queue when post-client exposes one", async () => {
service.postClient = {
getRetryQueue: () => [],
} as never;
const text = await runHealth();
expect(text).toContain("Retry queue: empty");
});

it("reports retry queue contents when non-empty", async () => {
service.postClient = {
getRetryQueue: () => [
{ kind: "post" },
{ kind: "post" },
{ kind: "comment" },
],
} as never;
const text = await runHealth();
expect(text).toContain("Retry queue: 3 pending");
expect(text).toContain("2×post");
expect(text).toContain("1×comment");
});

it("swallows errors from the retry queue accessor", async () => {
service.postClient = {
getRetryQueue: () => {
throw new Error("queue backend down");
},
} as never;
const text = await runHealth();
// Line should be absent rather than crash — health report is non-throwing.
expect(text).not.toContain("Retry queue:");
});

it("surfaces notification digest count when > 0", async () => {
service.stats!.notificationDigestsEmitted = 5;
const text = await runHealth();
expect(text).toContain("Notification digests this session: 5");
});

it("omits the digest line when count is 0", async () => {
service.stats!.notificationDigestsEmitted = 0;
const text = await runHealth();
expect(text).not.toContain("Notification digests");
});

it("surfaces adaptive poll multiplier when enabled", async () => {
service.colonyConfig.adaptivePollEnabled = true;
service.computeLlmHealthMultiplier!.mockReturnValue(2.5);
const text = await runHealth();
expect(text).toContain("Adaptive poll multiplier: 2.50×");
expect(text).toContain("slowing polls under LLM stress");
});

it("omits the adaptive-poll line when disabled", async () => {
service.colonyConfig.adaptivePollEnabled = false;
const text = await runHealth();
expect(text).not.toContain("Adaptive poll");
});

it("surfaces diversity watchdog peak when available", async () => {
service.diversityWatchdog = {
peakPairwiseSimilarity: () => 0.75,
};
service.colonyConfig.diversityThreshold = 0.8;
const text = await runHealth();
expect(text).toContain("Output diversity: peak pairwise 0.75");
expect(text).toContain("threshold 0.80");
});

it("adds a warning on the diversity line when peak is within 90% of threshold", async () => {
service.diversityWatchdog = {
peakPairwiseSimilarity: () => 0.78, // 0.78 >= 0.8 * 0.9 = 0.72
};
service.colonyConfig.diversityThreshold = 0.8;
const text = await runHealth();
expect(text).toContain("⚠️");
});

it("tolerates a null diversity watchdog (not configured)", async () => {
service.diversityWatchdog = null;
const text = await runHealth();
expect(text).not.toContain("Output diversity");
});

it("swallows errors from the diversity accessor", async () => {
service.diversityWatchdog = {
peakPairwiseSimilarity: () => {
throw new Error("ring corrupted");
},
};
const text = await runHealth();
expect(text).not.toContain("Output diversity");
});

it("returns early when service is not registered (non-throwing)", async () => {
const runtime = fakeRuntime(null);
const cb = makeCallback();
await expect(
colonyHealthReportAction.handler!(
runtime,
taggedMessage("are you healthy?"),
fakeState(),
{},
cb,
),
).resolves.toBeUndefined();
expect(cb).not.toHaveBeenCalled();
});

it("handles a null peak-pairwise-similarity return", async () => {
service.diversityWatchdog = {
peakPairwiseSimilarity: () => null,
};
const text = await runHealth();
expect(text).not.toContain("Output diversity");
});

it("returns with an 'unknown' handle when service has no username", async () => {
service.username = undefined;
const text = await runHealth();
expect(text).toContain("Health report for (unknown)");
});
});
Loading
Loading