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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ node_modules/
*/persona.json
.claude/

# Local agent CLI/tooling state + build output — never commit
.agentworkforce/
.test-build/
# OpenCode CLI config — contains live relay credentials, must stay local
opencode.json

# Relay VFS runtime state — rewritten on every reconcile cycle, never commit it
.relay/
memory/workspace/.relay/
Expand Down
11 changes: 11 additions & 0 deletions daytona-monitor/fixtures/inbox-test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"id": "evt-inbox-test-1",
"workspace": "ws-test",
"type": "relaycast.message",
"occurredAt": "2026-06-17T12:00:00Z",
"resource": {
"channel": "dm_inbox_test",
"messageId": "m-inbox-test-1",
"text": "What's the current sandbox status? Any errors or stale sandboxes running?"
}
}
2 changes: 2 additions & 0 deletions evals/cases.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@
{"id":"granola.note-classify","agent":"granola","kind":"triage","fixture":{"type":"granola.file.created","provider":"granola","paths":["/granola/notes/note-2.json"],"resource":{"path":"/granola/notes/note-2.json","kind":"file","id":"note-2","provider":"granola"}},"inputs":{},"seeds":[{"vfs":"/granola/notes/note-2.json","file":"granola-note-prospect.json"}],"expect":{"status":"succeeded","eventSource":"granola","sideEffectsAll":["llm.complete"],"logsAny":["granola-prospect.not-a-prospect"]},"rubric":"Reads the meeting note and classifies whether it describes a sales prospect before doing any Linear/PR work."}
{"id":"cloud-team-implementer.misroute","agent":"cloud-team-implementer","kind":"guard","fixture":{"type":"cron.tick","name":"tick","cron":"0 * * * *"},"inputs":{},"seeds":[],"expect":{"status":"succeeded","eventSource":"cron","logsAny":["cloud-team-implementer received a direct event; members are launched by the team dispatcher, not by subscriptions"]},"rubric":"A team member should never act on a direct event — it warns that it is launched by the dispatcher."}
{"id":"cloud-team-reviewer.misroute","agent":"cloud-team-reviewer","kind":"guard","fixture":{"type":"cron.tick","name":"tick","cron":"0 * * * *"},"inputs":{},"seeds":[],"expect":{"status":"succeeded","eventSource":"cron","logsAny":["cloud-team-reviewer received a direct event; members are launched by the team dispatcher, not by subscriptions"]},"rubric":"A team member should never act on a direct event — it warns that it is launched by the dispatcher."}
{"id":"inbox-buddy.chat","agent":"inbox-buddy","kind":"chat","fixture":{"type":"slack.app_mention","resource":{"channel":"C0TEST","ts":"100.1","text":"What's the latest on the Q3 export thread with Alice?","user":"U1"}},"inputs":{"SLACK_CHANNEL":"C0TEST"},"seeds":[{"vfs":"/google-mail/threads/T_alice_export.json","file":"gmail-thread-alice-export.json"},{"vfs":"/google-mail/threads/T_github_pr.json","file":"gmail-thread-github-pr.json"},{"vfs":"/google-mail/threads/T_bob_lunch.json","file":"gmail-thread-bob-lunch.json"},{"vfs":"/google-mail/threads/T_newsletter.json","file":"gmail-thread-newsletter.json"}],"expect":{"status":"succeeded","eventSource":"slack","sideEffectsAny":["llm.complete"],"logsAny":["inbox-buddy.context"],"replyContains":["Friday","finance"]},"rubric":"A grounded answer about the Q3 export thread with Alice, citing concrete details from that thread (final numbers by Friday, finance looped in for sign-off). Must not fabricate senders or facts."}
{"id":"inbox-buddy.chat-multiturn","agent":"inbox-buddy","kind":"chat","turns":[{"type":"slack.app_mention","channel":"C0TEST","ts":"100.1","text":"What's the latest on the Q3 export thread with Alice?","user":"U1"},{"type":"slack.app_mention","channel":"C0TEST","ts":"100.2","text":"Who did she loop in, and when did she say she'd send it?","user":"U1"}],"inputs":{"SLACK_CHANNEL":"C0TEST"},"seeds":[{"vfs":"/google-mail/threads/T_alice_export.json","file":"gmail-thread-alice-export.json"},{"vfs":"/google-mail/threads/T_github_pr.json","file":"gmail-thread-github-pr.json"},{"vfs":"/google-mail/threads/T_bob_lunch.json","file":"gmail-thread-bob-lunch.json"},{"vfs":"/google-mail/threads/T_newsletter.json","file":"gmail-thread-newsletter.json"}],"expect":{"status":"succeeded","eventSource":"slack","sideEffectsAny":["llm.complete"],"logsAny":["inbox-buddy.context"],"replyContains":["finance@acme.com","Friday"]},"rubric":"Two-turn conversation. The second answer must correctly state that Alice looped in finance (finance@acme.com) and said she would send the numbers by Friday — facts that appear only in the first thread/turn, so a correct, specific answer proves the agent retained context from turn 1. Must name Alice/finance explicitly and not fabricate."}
42 changes: 42 additions & 0 deletions evals/seeds/gmail-thread-alice-export.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"id": "T_alice_export",
"historyId": "100300",
"snippet": "Sounds good — I'll send the final Q3 export numbers by Friday.",
"messageIds": ["m_ax1", "m_ax2", "m_ax3"],
"messageCount": 3,
"messages": [
{
"id": "m_ax1",
"threadId": "T_alice_export",
"labelIds": ["INBOX", "IMPORTANT", "CATEGORY_PERSONAL"],
"snippet": "Hi — can you pull the Q3 revenue export before our review? Need the by-region breakdown.",
"internalDate": "1780790400000",
"subject": "Q3 revenue export",
"from": "\"Alice Chen\" <alice@acme.com>",
"to": "\"You\" <me@example.com>",
"date": "Sun, 07 Jun 2026 09:00:00 -0700"
},
{
"id": "m_ax2",
"threadId": "T_alice_export",
"labelIds": ["INBOX", "SENT"],
"snippet": "On it. The by-region breakdown needs the new currency conversion — will have a draft tomorrow.",
"internalDate": "1780876800000",
"subject": "Re: Q3 revenue export",
"from": "\"You\" <me@example.com>",
"to": "\"Alice Chen\" <alice@acme.com>",
"date": "Mon, 08 Jun 2026 09:00:00 -0700"
},
{
"id": "m_ax3",
"threadId": "T_alice_export",
"labelIds": ["INBOX", "IMPORTANT", "UNREAD"],
"snippet": "Sounds good — I'll send the final Q3 export numbers by Friday. Also looping in finance for sign-off.",
"internalDate": "1780963200000",
"subject": "Re: Q3 revenue export",
"from": "\"Alice Chen\" <alice@acme.com>",
"to": "\"You\" <me@example.com>, \"Finance\" <finance@acme.com>",
"date": "Tue, 09 Jun 2026 09:00:00 -0700"
}
]
}
20 changes: 20 additions & 0 deletions evals/seeds/gmail-thread-bob-lunch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"id": "T_bob_lunch",
"historyId": "100200",
"snippet": "Lunch Thursday at noon works for me — see you at the usual spot.",
"messageIds": ["m_bl1"],
"messageCount": 1,
"messages": [
{
"id": "m_bl1",
"threadId": "T_bob_lunch",
"labelIds": ["INBOX", "CATEGORY_PERSONAL", "UNREAD"],
"snippet": "Lunch Thursday at noon works for me — see you at the usual spot. Want to talk through the hiring plan.",
"internalDate": "1780531200000",
"subject": "lunch this week?",
"from": "\"Bob Diaz\" <bob@example.org>",
"to": "\"You\" <me@example.com>",
"date": "Thu, 04 Jun 2026 12:00:00 -0700"
}
]
}
31 changes: 31 additions & 0 deletions evals/seeds/gmail-thread-github-pr.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"id": "T_github_pr",
"historyId": "100250",
"snippet": "github-actions[bot] left a comment: all checks have passed on PR #481.",
"messageIds": ["m_gh1", "m_gh2"],
"messageCount": 2,
"messages": [
{
"id": "m_gh1",
"threadId": "T_github_pr",
"labelIds": ["INBOX", "CATEGORY_UPDATES"],
"snippet": "alice opened a pull request: Add CSV streaming to the export endpoint (#481).",
"internalDate": "1780617600000",
"subject": "[acme/widget] Add CSV streaming to the export endpoint (PR #481)",
"from": "\"Alice Chen\" <notifications@github.com>",
"to": "\"acme/widget\" <widget@noreply.github.com>",
"date": "Fri, 05 Jun 2026 12:00:00 -0700"
},
{
"id": "m_gh2",
"threadId": "T_github_pr",
"labelIds": ["INBOX", "CATEGORY_UPDATES", "UNREAD"],
"snippet": "github-actions[bot] left a comment: all checks have passed on PR #481. Ready for review.",
"internalDate": "1780704000000",
"subject": "Re: [acme/widget] Add CSV streaming to the export endpoint (PR #481)",
"from": "\"github-actions[bot]\" <notifications@github.com>",
"to": "\"acme/widget\" <widget@noreply.github.com>",
"date": "Sat, 06 Jun 2026 12:00:00 -0700"
}
]
}
20 changes: 20 additions & 0 deletions evals/seeds/gmail-thread-newsletter.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"id": "T_newsletter",
"historyId": "100100",
"snippet": "This week in AI agents: new runtime patterns, eval harnesses, and more.",
"messageIds": ["m_nl1"],
"messageCount": 1,
"messages": [
{
"id": "m_nl1",
"threadId": "T_newsletter",
"labelIds": ["INBOX", "CATEGORY_PROMOTIONS"],
"snippet": "This week in AI agents: new runtime patterns, eval harnesses, and more. Read online.",
"internalDate": "1780513200000",
"subject": "The Agent Weekly — issue #58",
"from": "\"Agent Weekly\" <hello@agentweekly.example>",
"to": "\"You\" <me@example.com>",
"date": "Wed, 03 Jun 2026 12:00:00 -0700"
}
]
}
2 changes: 2 additions & 0 deletions gcp-watcher/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
GCP Watcher
===========

<img src="./banner.png" alt="GCP Watcher">

A proactive agent that watches your GCP project's **Cloud Run services**,
**Monitoring alert policies**, and **billing/cost**, and posts a Slack alert only
when something needs attention. Notification-only — it never mutates GCP.
Expand Down
Binary file added gcp-watcher/avatar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added gcp-watcher/banner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added gcp-watcher/card.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
76 changes: 49 additions & 27 deletions hn-monitor/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export async function handleInboxMessage(
ctx.log('info', 'hn-monitor.inbox.recalled', { posts: posts.length });

const context = posts.length
? posts.map((p) => `### Posted ${p.postedAt}\n${p.digest}`).join('\n\n')
? posts.map((p) => `### Posted ${p.postedAt ?? 'Unknown'}\n${p.digest ?? ''}`).join('\n\n')
: 'No Hacker News digests have been posted yet.';

const prompt = [
Expand All @@ -117,7 +117,7 @@ export async function handleInboxMessage(
} catch (error) {
ctx.log('warn', 'hn-monitor.llm-fallback', { error: String(error) });
const titles = posts
.flatMap((p) => p.stories.map((s) => `- ${s.title} ${s.url}`))
.flatMap((p) => (p.stories ?? []).map((s) => `- ${s.title ?? 'Untitled'} ${s.url ?? ''}`))
.slice(0, 15)
.join('\n');
answer = titles
Expand All @@ -130,7 +130,11 @@ export async function handleInboxMessage(
}

interface SlackPoster {
post(channel: string, text: string): Promise<{ ts: string }>;
// Mirrors @relayfile/relay-helpers slackClient().post: returns the delivered
// `ts` plus a draft `ref`. Passing a prior post's `ref` as `opts.replyTo`
// threads the new message under it via the cloud's server-side ordered dispatch
// (no parent-receipt round-trip — see slack.d.ts).
post(channel: string, text: string, opts?: { replyTo?: string }): Promise<{ ts: string; ref?: string }>;
}

export async function postFreshStories(
Expand All @@ -146,33 +150,50 @@ export async function postFreshStories(
// first means a concurrent re-invocation loads these ids as already-seen and
// stays silent instead of double-posting.
await saveSeen(ctx, [...seen, ...fresh.map((s) => s.id)].slice(-200));
// Once the header lands in the channel, a thrown handler is RETRIED by the
// runtime and would re-post a duplicate header — so only release the claim +
// rethrow while nothing has been posted yet (see catch below). Mirrors the
// server-side threading pattern in internal-agents x-reply-radar.
let headerPosted = false;
try {
ctx.log('info', 'hn-monitor.summarizing', { fresh: fresh.length });
const digest = await summarize(ctx, fresh);
const { header, body } = await summarize(ctx, fresh);
ctx.log('info', 'hn-monitor.posting', { channel });

// Thread the digest under a compact count header: post the header, then post
// the body with `replyTo: head.ref` so the cloud orders it after the header
// delivers and sets thread_ts server-side — no parent-receipt round-trip.
// post() resolves with ts:'' (no throw) when the writeback gets no receipt,
// so an empty ts is a SILENT DROP, not success — make it a loud failure
// (matches daytona-monitor / pr-reviewer). Without this, a dropped digest
// looked like a success and was never retried.
ctx.log('info', 'hn-monitor.posting', { channel });
const res = await client.post(channel, digest);
if (!res.ts) throw new Error(`Slack post to ${channel} got no writeback receipt (silent drop)`);
ctx.log('info', 'hn-monitor.posted', { ts: res.ts });
// (matches daytona-monitor / pr-reviewer).
const head = await client.post(channel, header);
if (!head.ts) throw new Error(`Slack header post to ${channel} got no writeback receipt (silent drop)`);
headerPosted = true;
ctx.log('info', 'hn-monitor.header-posted', { ts: head.ts });
const reply = await client.post(channel, body, { replyTo: head.ref });
if (!reply.ts) throw new Error(`Slack threaded digest to ${channel} got no writeback receipt (silent drop)`);
ctx.log('info', 'hn-monitor.posted', { ts: head.ts, threadTs: reply.ts });

// Retain the digest so a user can DM the agent and ask about recent posts.
// ttlDays (30) on memory ages these out, giving a rolling ~30-day window.
await savePost(ctx, {
postedAt: new Date().toISOString(),
digest,
digest: `${header}\n${body}`,
stories: fresh.map((s) => ({ title: s.title, url: s.url, points: s.points }))
});
} catch (err) {
// The claim was provisional: summarize or post failed, so RELEASE it by
// restoring the prior seen set. The next tick then retries this digest
// instead of dropping it forever — the old behavior marked the stories seen
// permanently, so a single transient LLM/Slack failure made the agent go
// silent for good. Releasing keeps the double-post guard (ids stay claimed
// for the duration of the attempt) while making a failed run self-heal.
await saveSeen(ctx, seen).catch(() => {});
throw err;
if (!headerPosted) {
// Nothing landed in the channel yet (summarize or the header post failed),
// so the claim was provisional: RELEASE it by restoring the prior seen set
// and rethrow, so the next tick retries this digest instead of dropping it
// forever. Releasing keeps the double-post guard (ids stay claimed for the
// duration of the attempt) while making a failed run self-heal.
await saveSeen(ctx, seen).catch(() => {});
throw err;
}
// The header already posted; releasing + rethrowing would make the runtime's
// retry re-post a duplicate header. Keep the claim and log loudly instead.
ctx.log('error', 'hn-monitor.thread-incomplete', { error: err instanceof Error ? err.message : String(err) });
}
}

Expand All @@ -194,13 +215,14 @@ async function fetchFrontPage(): Promise<Story[]> {
}
}

async function summarize(ctx: WorkforceCtx, stories: Story[]): Promise<string> {
/** Split into the count `header` (the channel-level parent message) and the
* `body` (the digest, threaded under it). ctx.llm.complete() can hang
* indefinitely (cloud/runtime bug — see PR) or error, so summarize() must
* ALWAYS return a postable body: race the call against a timeout, and on
* timeout/error fall back to a plain bulleted digest from the story lines. */
async function summarize(ctx: WorkforceCtx, stories: Story[]): Promise<{ header: string; body: string }> {
const lines = stories.map((s) => `- ${s.title} (${s.points} pts) ${s.url}`).join('\n');
const header = `:newspaper: *Hacker News* — ${stories.length} new match(es)`;
// ctx.llm.complete() can hang indefinitely (cloud/runtime bug — see PR) or
// error. summarize() must ALWAYS return a postable string so the digest still
// lands: race the call against a timeout, and on timeout/error fall back to a
// plain bulleted digest built from the story lines we already have.
try {
const digest = await withTimeout(
ctx.llm.complete(
Expand All @@ -210,10 +232,10 @@ async function summarize(ctx: WorkforceCtx, stories: Story[]): Promise<string> {
45_000,
'ctx.llm.complete'
);
return `${header}\n${digest.trim()}`;
return { header, body: digest.trim() };
} catch (error) {
ctx.log('warn', 'hn-monitor.llm-fallback', { error: String(error) });
return `${header}\n${lines}`;
return { header, body: lines };
}
}

Expand Down Expand Up @@ -272,5 +294,5 @@ async function loadPosts(ctx: WorkforceCtx): Promise<PostRecord[]> {
// skip records that aren't valid JSON
}
}
return posts.sort((a, b) => (a.postedAt < b.postedAt ? 1 : -1));
return posts.sort((a, b) => (b.postedAt ?? '').localeCompare(a.postedAt ?? ''));
}
Loading