Skip to content

Commit f85414e

Browse files
chitcommitclaude
andcommitted
feat: add ActionAgent Durable Object with tool-use chat via Agents SDK
Integrate Cloudflare Agents SDK to provide a persistent conversational agent with financial tool-use and ChittyStorage document access. Each authenticated user gets an isolated DO instance with auto-persisted chat history in SQLite. - ActionAgent (AIChatAgent<Env>) with Workers AI (Llama 3.3 70B) - 7 financial tools: snapshot, obligations, disputes, recommendations, approve_action, legal deadlines, cashflow projections - 4 storage tools: search, classify, list entity docs, ingest (proxied to storage.chitty.cc MCP endpoint) - Mounted at /agent/* behind existing authMiddleware - Upgraded zod v3 → v4 (required by agents SDK, backward compatible) - Added deps: @cloudflare/ai-chat, hono-agents, workers-ai-provider, ai@6 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 37d2b46 commit f85414e

8 files changed

Lines changed: 2476 additions & 597 deletions

File tree

package-lock.json

Lines changed: 2068 additions & 596 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,15 @@
2121
"test:watch": "vitest"
2222
},
2323
"dependencies": {
24+
"@cloudflare/ai-chat": "^0.1.9",
2425
"@hono/zod-validator": "^0.7.6",
2526
"@neondatabase/serverless": "^0.10.0",
27+
"ai": "^6.0.141",
2628
"drizzle-orm": "^0.45.1",
2729
"hono": "^4.12.5",
28-
"zod": "^3.23.0"
30+
"hono-agents": "^3.0.7",
31+
"workers-ai-provider": "^3.1.8",
32+
"zod": "^4.3.6"
2933
},
3034
"devDependencies": {
3135
"@cloudflare/workers-types": "^4.20240512.0",

src/agents/action-agent.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { AIChatAgent } from '@cloudflare/ai-chat';
2+
import { createWorkersAI } from 'workers-ai-provider';
3+
import { streamText, convertToModelMessages, stepCountIs } from 'ai';
4+
import type { StreamTextOnFinishCallback, ToolSet } from 'ai';
5+
import type { OnChatMessageOptions } from '@cloudflare/ai-chat';
6+
import { getDb } from '../lib/db';
7+
import { buildSystemPrompt } from './system-prompt';
8+
import { createFinancialTools } from './tools/financial';
9+
import { createStorageTools } from './tools/storage';
10+
import type { Env } from '../index';
11+
12+
/**
13+
* ActionAgent — persistent conversational agent with tool-use.
14+
*
15+
* Each user gets their own Durable Object instance (keyed by userId).
16+
* Conversation history is automatically persisted in DO SQLite.
17+
* Tools provide read/write access to ChittyCommand's financial data
18+
* and ChittyStorage's document management.
19+
*/
20+
export class ActionAgent extends AIChatAgent<Env> {
21+
maxPersistedMessages = 200;
22+
23+
async onChatMessage(
24+
onFinish: StreamTextOnFinishCallback<ToolSet>,
25+
options?: OnChatMessageOptions,
26+
): Promise<Response> {
27+
const sql = getDb(this.env);
28+
29+
// Build system prompt with live financial context
30+
let systemPrompt: string;
31+
try {
32+
systemPrompt = await buildSystemPrompt(sql);
33+
} catch (err) {
34+
console.error('[action-agent] system prompt build failed:', err instanceof Error ? err.message : err);
35+
systemPrompt = 'You are the ChittyCommand ActionAgent. Database context is temporarily unavailable — inform the user and offer to retry.';
36+
}
37+
38+
// Create Workers AI model
39+
const workersai = createWorkersAI({ binding: this.env.AI });
40+
const model = workersai('@cf/meta/llama-3.3-70b-instruct-fp8-fast');
41+
42+
// Combine all tool sets
43+
const tools = {
44+
...createFinancialTools(sql),
45+
...createStorageTools(),
46+
};
47+
48+
const modelMessages = await convertToModelMessages(this.messages);
49+
50+
const result = streamText({
51+
model,
52+
system: systemPrompt,
53+
messages: modelMessages,
54+
tools,
55+
stopWhen: stepCountIs(5),
56+
abortSignal: options?.abortSignal,
57+
onFinish: onFinish as unknown as StreamTextOnFinishCallback<typeof tools>,
58+
});
59+
60+
return result.toUIMessageStreamResponse();
61+
}
62+
}

src/agents/system-prompt.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { NeonQueryFunction } from '@neondatabase/serverless';
2+
3+
/**
4+
* Build the system prompt for the ActionAgent with live financial context.
5+
* Extracted from src/routes/chat.ts and enhanced with dispute/legal awareness.
6+
*/
7+
export async function buildSystemPrompt(
8+
sql: NeonQueryFunction<false, false>,
9+
): Promise<string> {
10+
const [[cash], [overdue], [dueSoon], [disputes], [deadlines]] = await Promise.all([
11+
sql`SELECT COALESCE(SUM(current_balance), 0) as total
12+
FROM cc_accounts WHERE account_type IN ('checking', 'savings')`,
13+
sql`SELECT COUNT(*) as count,
14+
COALESCE(SUM(COALESCE(amount_due::numeric, 0)), 0) as total
15+
FROM cc_obligations WHERE status = 'overdue'`,
16+
sql`SELECT COUNT(*) as count
17+
FROM cc_obligations
18+
WHERE status = 'pending' AND due_date <= CURRENT_DATE + INTERVAL '7 days'`,
19+
sql`SELECT COUNT(*) as count
20+
FROM cc_disputes WHERE status NOT IN ('resolved', 'dismissed')`,
21+
sql`SELECT COUNT(*) as count
22+
FROM cc_legal_deadlines
23+
WHERE status = 'pending' AND deadline_date <= CURRENT_DATE + INTERVAL '14 days'`,
24+
]);
25+
26+
return `You are the ChittyCommand ActionAgent — an AI financial advisor and action executor embedded in a life management dashboard.
27+
28+
Current financial snapshot:
29+
- Cash position: $${Number(cash.total).toLocaleString()}
30+
- Overdue bills: ${overdue.count} totaling $${Number(overdue.total).toLocaleString()}
31+
- Due this week: ${dueSoon.count}
32+
- Active disputes: ${disputes.count}
33+
- Upcoming legal deadlines (14 days): ${deadlines.count}
34+
35+
You have tools to query obligations, disputes, documents, legal deadlines, cash flow projections, and recommendations. You can also search and classify documents via ChittyStorage.
36+
37+
When a user asks to take an action (pay a bill, approve a recommendation), use the appropriate tool. Write operations require user confirmation — the tool will prompt for approval automatically.
38+
39+
Be concise and direct. Use dollar amounts and dates. When you don't know something, use a tool to look it up rather than guessing.`;
40+
}

src/agents/tools/financial.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { tool } from 'ai';
2+
import { z } from 'zod';
3+
import type { NeonQueryFunction } from '@neondatabase/serverless';
4+
5+
/**
6+
* Create financial tools bound to a Neon SQL connection.
7+
* Each tool executes read-only or guarded-write queries against cc_* tables.
8+
*/
9+
export function createFinancialTools(sql: NeonQueryFunction<false, false>) {
10+
return {
11+
get_financial_snapshot: tool({
12+
description: 'Get current cash position, overdue bills, and upcoming obligations.',
13+
inputSchema: z.object({}),
14+
execute: async () => {
15+
const [[cash], [overdue], [dueSoon], [activeRecs]] = await Promise.all([
16+
sql`SELECT COALESCE(SUM(current_balance), 0) as total,
17+
COUNT(*) as account_count
18+
FROM cc_accounts WHERE account_type IN ('checking', 'savings')`,
19+
sql`SELECT COUNT(*) as count,
20+
COALESCE(SUM(COALESCE(amount_due::numeric, 0)), 0) as total
21+
FROM cc_obligations WHERE status = 'overdue'`,
22+
sql`SELECT COUNT(*) as count
23+
FROM cc_obligations
24+
WHERE status = 'pending' AND due_date <= CURRENT_DATE + INTERVAL '7 days'`,
25+
sql`SELECT COUNT(*) as count
26+
FROM cc_recommendations WHERE status = 'active'`,
27+
]);
28+
return {
29+
cash_position: Number(cash.total),
30+
account_count: Number(cash.account_count),
31+
overdue_count: Number(overdue.count),
32+
overdue_total: Number(overdue.total),
33+
due_this_week: Number(dueSoon.count),
34+
pending_recommendations: Number(activeRecs.count),
35+
};
36+
},
37+
}),
38+
39+
query_obligations: tool({
40+
description: 'Search obligations (bills) by status, category, or payee. Returns up to 20 results.',
41+
inputSchema: z.object({
42+
status: z.enum(['pending', 'overdue', 'paid', 'deferred']).optional().describe('Filter by status'),
43+
category: z.string().optional().describe('Filter by category (e.g., "mortgage", "utility", "insurance")'),
44+
payee: z.string().optional().describe('Search payee name (partial match)'),
45+
}),
46+
execute: async ({ status, category, payee }) => {
47+
// Build dynamic query with optional filters
48+
const conditions: string[] = [];
49+
const params: unknown[] = [];
50+
let paramIdx = 1;
51+
52+
if (status) {
53+
conditions.push(`status = $${paramIdx++}`);
54+
params.push(status);
55+
}
56+
if (category) {
57+
conditions.push(`category ILIKE $${paramIdx++}`);
58+
params.push(`%${category}%`);
59+
}
60+
if (payee) {
61+
conditions.push(`payee ILIKE $${paramIdx++}`);
62+
params.push(`%${payee}%`);
63+
}
64+
65+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
66+
const rows = await sql(
67+
`SELECT id, payee, amount_due, due_date, status, category, auto_pay, urgency_score
68+
FROM cc_obligations ${where}
69+
ORDER BY due_date ASC NULLS LAST LIMIT 20`,
70+
params,
71+
);
72+
return { obligations: rows, count: rows.length };
73+
},
74+
}),
75+
76+
query_disputes: tool({
77+
description: 'Search active disputes by status or type.',
78+
inputSchema: z.object({
79+
status: z.enum(['open', 'pending', 'escalated', 'resolved', 'dismissed']).optional(),
80+
dispute_type: z.string().optional().describe('Filter by dispute type'),
81+
}),
82+
execute: async ({ status, dispute_type }) => {
83+
const conditions: string[] = [];
84+
const params: unknown[] = [];
85+
let paramIdx = 1;
86+
87+
if (status) {
88+
conditions.push(`status = $${paramIdx++}`);
89+
params.push(status);
90+
}
91+
if (dispute_type) {
92+
conditions.push(`dispute_type ILIKE $${paramIdx++}`);
93+
params.push(`%${dispute_type}%`);
94+
}
95+
96+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
97+
const rows = await sql(
98+
`SELECT id, title, counterparty, dispute_type, amount_claimed, stage, status, priority, next_action, next_action_date
99+
FROM cc_disputes ${where}
100+
ORDER BY priority ASC LIMIT 20`,
101+
params,
102+
);
103+
return { disputes: rows, count: rows.length };
104+
},
105+
}),
106+
107+
get_recommendations: tool({
108+
description: 'Get active recommendations from the action queue, enriched with obligation details.',
109+
inputSchema: z.object({
110+
limit: z.number().min(1).max(20).optional().describe('Number of results (default 10)'),
111+
}),
112+
execute: async ({ limit }) => {
113+
const n = limit ?? 10;
114+
const rows = await sql`
115+
SELECT r.id, r.rec_type, r.priority, r.title, r.reasoning,
116+
r.action_type, r.estimated_savings, r.confidence,
117+
r.suggested_amount, r.escalation_risk,
118+
o.payee, o.amount_due, o.due_date, o.category, o.status as ob_status
119+
FROM cc_recommendations r
120+
LEFT JOIN cc_obligations o ON r.obligation_id = o.id
121+
WHERE r.status = 'active'
122+
ORDER BY r.priority ASC
123+
LIMIT ${n}
124+
`;
125+
return { recommendations: rows, count: rows.length };
126+
},
127+
}),
128+
129+
approve_action: tool({
130+
description: 'Approve a recommendation from the action queue. This is a WRITE operation that marks the recommendation as completed and logs the action. Use this when the user explicitly asks to approve or execute a recommendation.',
131+
inputSchema: z.object({
132+
recommendation_id: z.string().uuid().describe('The recommendation ID to approve'),
133+
action_notes: z.string().optional().describe('Optional notes about the action taken'),
134+
}),
135+
execute: async ({ recommendation_id, action_notes }) => {
136+
// Verify the recommendation exists and is active
137+
const [rec] = await sql`
138+
SELECT id, title, action_type FROM cc_recommendations
139+
WHERE id = ${recommendation_id}::uuid AND status = 'active'
140+
`;
141+
if (!rec) {
142+
return { success: false, error: 'Recommendation not found or already completed' };
143+
}
144+
145+
// Mark as completed and log the action
146+
await sql`UPDATE cc_recommendations SET status = 'completed' WHERE id = ${recommendation_id}::uuid`;
147+
await sql`
148+
INSERT INTO cc_actions_log (action_type, target_type, target_id, description, status)
149+
VALUES ('recommendation_acted', 'recommendation', ${recommendation_id}, ${action_notes || rec.title}, 'completed')
150+
`;
151+
152+
return { success: true, recommendation: rec.title, action_type: rec.action_type };
153+
},
154+
}),
155+
156+
get_legal_deadlines: tool({
157+
description: 'Get upcoming legal deadlines within a specified number of days.',
158+
inputSchema: z.object({
159+
days_ahead: z.number().min(1).max(90).optional().describe('Days to look ahead (default 30)'),
160+
}),
161+
execute: async ({ days_ahead }) => {
162+
const days = days_ahead ?? 30;
163+
const rows = await sql`
164+
SELECT id, case_ref, title, deadline_type, deadline_date, status, urgency_score
165+
FROM cc_legal_deadlines
166+
WHERE status = 'pending'
167+
AND deadline_date <= CURRENT_DATE + (${days} || ' days')::interval
168+
ORDER BY deadline_date ASC
169+
`;
170+
return { deadlines: rows, count: rows.length };
171+
},
172+
}),
173+
174+
get_cashflow_projection: tool({
175+
description: 'Get the latest cash flow projection showing expected inflows, outflows, and balance.',
176+
inputSchema: z.object({}),
177+
execute: async () => {
178+
const rows = await sql`
179+
SELECT projection_date, projected_inflow, projected_outflow, projected_balance, confidence
180+
FROM cc_cashflow_projections
181+
ORDER BY projection_date ASC
182+
LIMIT 30
183+
`;
184+
return { projections: rows, count: rows.length };
185+
},
186+
}),
187+
};
188+
}

0 commit comments

Comments
 (0)