From 001a09089ff6ff1eaa427299e1562d707f89ec64 Mon Sep 17 00:00:00 2001 From: tsuz <6927131+tsuz@users.noreply.github.com> Date: Sat, 23 May 2026 13:58:29 +0900 Subject: [PATCH 1/2] add confluent cloud example --- .../README.md | 158 +++++++++++++++ .../docker-compose.yml | 78 ++++++++ .../lead-tool-service/Dockerfile | 15 ++ .../lead-tool-service/app.py | 149 ++++++++++++++ .../lead-tool-service/requirements.txt | 1 + .../lead-tool-service/test_app_sdk.py | 181 ++++++++++++++++++ .../system-prompt.txt | 15 ++ .../tools.json | 112 +++++++++++ 8 files changed, 709 insertions(+) create mode 100644 examples/lead-followup-agent-confluent-cloud/README.md create mode 100644 examples/lead-followup-agent-confluent-cloud/docker-compose.yml create mode 100644 examples/lead-followup-agent-confluent-cloud/lead-tool-service/Dockerfile create mode 100644 examples/lead-followup-agent-confluent-cloud/lead-tool-service/app.py create mode 100644 examples/lead-followup-agent-confluent-cloud/lead-tool-service/requirements.txt create mode 100644 examples/lead-followup-agent-confluent-cloud/lead-tool-service/test_app_sdk.py create mode 100644 examples/lead-followup-agent-confluent-cloud/system-prompt.txt create mode 100644 examples/lead-followup-agent-confluent-cloud/tools.json diff --git a/examples/lead-followup-agent-confluent-cloud/README.md b/examples/lead-followup-agent-confluent-cloud/README.md new file mode 100644 index 0000000..a3bb997 --- /dev/null +++ b/examples/lead-followup-agent-confluent-cloud/README.md @@ -0,0 +1,158 @@ +# Lead Follow-Up Agent — Confluent Cloud + +Same demo as [`lead-followup-agent`](../lead-followup-agent), but talks to a +**Confluent Cloud** cluster instead of a local Kafka broker. Every service +image is built from local source so you can test code changes against a real +cloud cluster without publishing images. + +Use this example to validate that a set of Confluent Cloud API keys works +end-to-end across all FlightDeck processors (Java + Python). + +## What's different from `lead-followup-agent` + +| | `lead-followup-agent` | this example | +|---|---|---| +| Kafka broker | Local container (`apache/kafka:4.1.1`) | Confluent Cloud (SASL_SSL / PLAIN) | +| Service images | Pulled from `ghcr.io/tsuz/flightdeck/*` | Built from local source | +| Auth env vars | none | `CONFLUENT_*` in `.env` | + +## Prerequisites + +1. A Confluent Cloud cluster (any type — Basic is fine for testing). +2. A **cluster-scoped API key + secret** (Cloud → Cluster → API Keys → Add Key). + The cluster's Cloud API key will NOT work — it must be scoped to the + specific cluster. +3. A Claude API key. +4. Docker + `docker compose`. + +## Pre-create topics in Confluent Cloud + +> **Why this is required:** the `processing` service tries to auto-create +> topics with replication factor 1, which Confluent Cloud rejects (CC enforces +> RF ≥ 3). So topics must exist *before* the stack starts. + +Topics use the agent name as a prefix. Assuming the default `AGENT_NAME=lead-followup`, +create these topics in the cluster (default partition count is fine, RF = 3): + +``` +lead-followup-message-input +lead-followup-enriched-message-input +lead-followup-think-request-response +lead-followup-tool-use +lead-followup-tool-use-result +lead-followup-tool-use-dlq +lead-followup-tool-use-all-complete +lead-followup-tool-use-latency +lead-followup-message-output +lead-followup-think-dlq +lead-followup-session-context +``` + +### Option A — Confluent Cloud Console + +UI → Cluster → Topics → Add topic — repeat for each name above. + +### Option B — `confluent` CLI + +```bash +confluent login +confluent kafka cluster use + +for t in \ + lead-followup-message-input \ + lead-followup-enriched-message-input \ + lead-followup-think-request-response \ + lead-followup-tool-use \ + lead-followup-tool-use-result \ + lead-followup-tool-use-dlq \ + lead-followup-tool-use-all-complete \ + lead-followup-tool-use-latency \ + lead-followup-message-output \ + lead-followup-think-dlq \ + lead-followup-session-context; do + confluent kafka topic create "$t" +done +``` + +If you change `AGENT_NAME`, change the topic prefix to match. + +## Configure credentials + +```bash +cp .env.example .env +``` + +Edit `.env` and fill in: + +| Variable | Where to get it | +|---|---| +| `CONFLUENT_BOOTSTRAP_SERVERS` | Cluster → Cluster settings → Bootstrap server (e.g. `pkc-921jm.us-east-2.aws.confluent.cloud:9092`) | +| `CONFLUENT_API_KEY` | Cluster → API Keys → your key | +| `CONFLUENT_API_SECRET` | Shown once when the key was created | +| `CLAUDE_API_KEY` | console.anthropic.com → API Keys | + +## Run + +```bash +docker compose up --build +``` + +First build takes a few minutes (Maven downloads + multi-stage Java builds). +Subsequent runs are cached. + +Open and try a prompt like: + +> "Find all dormant leads we haven't contacted since August 2025" + +## How auth is wired + +The processors all use the `KafkaEnvProps` pass-through pattern: any +`KAFKA_*` env var becomes a Kafka client config property (`KAFKA_FOO_BAR` → +`foo.bar`). The compose file sets these per service: + +**Java services** (`api`, `processing`, `think-consumer`) — Apache Kafka client uses JAAS: +``` +KAFKA_SECURITY_PROTOCOL=SASL_SSL +KAFKA_SASL_MECHANISM=PLAIN +KAFKA_SASL_JAAS_CONFIG=org.apache.kafka.common.security.plain.PlainLoginModule required username="..." password="..."; +``` + +**Python service** (`lead-tool-service`) — librdkafka takes credentials directly: +``` +KAFKA_SECURITY_PROTOCOL=SASL_SSL +KAFKA_SASL_MECHANISM=PLAIN +KAFKA_SASL_USERNAME=... +KAFKA_SASL_PASSWORD=... +``` + +`processing` additionally sets `KAFKA_REPLICATION_FACTOR=3` so Kafka Streams +creates its internal/changelog topics with the RF Confluent Cloud requires. + +## Verifying the keys work + +A successful end-to-end run means every service authenticated. To narrow down +the source of an auth failure: + +```bash +# Show only auth/connection errors +docker compose logs api processing think-consumer lead-tool-service | grep -iE 'sasl|auth|disconnect|topicauthorization' +``` + +Common failure modes: + +| Symptom | Likely cause | +|---|---| +| `SaslAuthenticationException: Authentication failed` | API key/secret wrong, or key not scoped to this cluster | +| `TopicAuthorizationException` on a `lead-followup-*` topic | ACL missing for the key — grant the key write/read on the topic prefix | +| `InvalidReplicationFactorException` from `processing` | A topic still missing — re-check the pre-create list above | +| `UnknownTopicOrPartitionException` | Topic name typo, or `AGENT_NAME` changed but topics not renamed | +| Hangs at startup, no errors | Bootstrap server hostname wrong, or outbound 9092 blocked | + +## Clean up + +```bash +docker compose down +``` + +Topics, ACLs, and the API key remain in Confluent Cloud — delete those from +the Cloud console if you don't want to be billed for storage. diff --git a/examples/lead-followup-agent-confluent-cloud/docker-compose.yml b/examples/lead-followup-agent-confluent-cloud/docker-compose.yml new file mode 100644 index 0000000..7eec533 --- /dev/null +++ b/examples/lead-followup-agent-confluent-cloud/docker-compose.yml @@ -0,0 +1,78 @@ +services: + + # ─── Application Services (built from local source) ───────────────────────── + + api: + build: + context: ../../api/chat-api + ports: + - "8002:8000" + - "8003:8001" + environment: + AGENT_NAME: ${AGENT_NAME:-lead-followup} + PORT: 8000 + WS_PORT: 8001 + # Confluent Cloud connection (Java client — uses JAAS) + KAFKA_BOOTSTRAP_SERVERS: ${CONFLUENT_BOOTSTRAP_SERVERS} + KAFKA_SECURITY_PROTOCOL: SASL_SSL + KAFKA_SASL_MECHANISM: PLAIN + KAFKA_SASL_JAAS_CONFIG: 'org.apache.kafka.common.security.plain.PlainLoginModule required username="${CONFLUENT_API_KEY}" password="${CONFLUENT_API_SECRET}";' + + processing: + build: + context: ../../processor-apps/processing + environment: + AGENT_NAME: ${AGENT_NAME:-lead-followup} + MEMOIR_ENABLED: "false" + # Confluent Cloud connection (Java client — uses JAAS) + KAFKA_BOOTSTRAP_SERVERS: ${CONFLUENT_BOOTSTRAP_SERVERS} + KAFKA_SECURITY_PROTOCOL: SASL_SSL + KAFKA_SASL_MECHANISM: PLAIN + KAFKA_SASL_JAAS_CONFIG: 'org.apache.kafka.common.security.plain.PlainLoginModule required username="${CONFLUENT_API_KEY}" password="${CONFLUENT_API_SECRET}";' + # Confluent Cloud requires RF >= 3 for Streams internal/changelog topics + KAFKA_REPLICATION_FACTOR: "3" + + think-consumer: + build: + context: ../../think/think-consumer + volumes: + - ./tools.json:/app/tools.json:ro + - ./system-prompt.txt:/app/system-prompt.txt:ro + environment: + AGENT_NAME: ${AGENT_NAME:-lead-followup} + CLAUDE_API_KEY: ${CLAUDE_API_KEY} + CLAUDE_MODEL: ${CLAUDE_MODEL:-claude-haiku-4-5-20251001} + CLAUDE_MAX_TOKENS: ${CLAUDE_MAX_TOKENS:-8096} + INPUT_TOKEN_PRICE: ${INPUT_TOKEN_PRICE:-1} + OUTPUT_TOKEN_PRICE: ${OUTPUT_TOKEN_PRICE:-5} + BUDGET_PRICE_PER_SESSION: ${BUDGET_PRICE_PER_SESSION:-0.5} + TOOLS_JSON_FILE: /app/tools.json + SYSTEM_PROMPT_FILE: /app/system-prompt.txt + # Confluent Cloud connection (Java client — uses JAAS) + KAFKA_BOOTSTRAP_SERVERS: ${CONFLUENT_BOOTSTRAP_SERVERS} + KAFKA_SECURITY_PROTOCOL: SASL_SSL + KAFKA_SASL_MECHANISM: PLAIN + KAFKA_SASL_JAAS_CONFIG: 'org.apache.kafka.common.security.plain.PlainLoginModule required username="${CONFLUENT_API_KEY}" password="${CONFLUENT_API_SECRET}";' + + lead-tool-service: + build: + context: ../.. + dockerfile: examples/lead-followup-agent-confluent-cloud/lead-tool-service/Dockerfile + environment: + AGENT_NAME: ${AGENT_NAME:-lead-followup} + # Confluent Cloud connection (Python / librdkafka — uses sasl.username + sasl.password) + KAFKA_BOOTSTRAP_SERVERS: ${CONFLUENT_BOOTSTRAP_SERVERS} + KAFKA_SECURITY_PROTOCOL: SASL_SSL + KAFKA_SASL_MECHANISM: PLAIN + KAFKA_SASL_USERNAME: ${CONFLUENT_API_KEY} + KAFKA_SASL_PASSWORD: ${CONFLUENT_API_SECRET} + + # ─── Frontend ──────────────────────────────────────────────────────────────── + + frontend: + build: + context: ../../frontend + ports: + - "8080:80" + depends_on: + - api diff --git a/examples/lead-followup-agent-confluent-cloud/lead-tool-service/Dockerfile b/examples/lead-followup-agent-confluent-cloud/lead-tool-service/Dockerfile new file mode 100644 index 0000000..c0a4750 --- /dev/null +++ b/examples/lead-followup-agent-confluent-cloud/lead-tool-service/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim +WORKDIR /app + +# Install SDK +COPY sdk/python /tmp/sdk +RUN pip install --no-cache-dir /tmp/sdk && rm -rf /tmp/sdk + +COPY examples/lead-followup-agent-confluent-cloud/lead-tool-service/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY examples/lead-followup-agent-confluent-cloud/lead-tool-service/app.py . + +ENV PYTHONUNBUFFERED=1 + +CMD ["python", "app.py"] diff --git a/examples/lead-followup-agent-confluent-cloud/lead-tool-service/app.py b/examples/lead-followup-agent-confluent-cloud/lead-tool-service/app.py new file mode 100644 index 0000000..8d9031a --- /dev/null +++ b/examples/lead-followup-agent-confluent-cloud/lead-tool-service/app.py @@ -0,0 +1,149 @@ +""" +Lead CRM tool consumer — rewritten using the flightdeck SDK. + +Compare with app.py to see how the SDK eliminates all Kafka boilerplate. +The developer only writes tool functions and a dispatch handler. +""" + +import json +import os +import time +from copy import deepcopy +from datetime import datetime, timezone + +from flightdeck_sdk import ToolConsumerRunner, ToolConsumerConfig + +# ── Mock CRM Database ──────────────────────────────────────────────────────── + +LEADS = { + "lead-001": { + "lead_id": "lead-001", + "name": "Jane Chen", + "email": "jane.chen@acmecorp.com", + "company": "Acme Corp", + "industry": "ecommerce", + "deal_value": 45000, + "status": "dormant", + "last_contact": "2025-08-12", + "created": "2025-03-01", + "activities": [ + {"date": "2025-03-01", "type": "note", "summary": "Inbound lead from website demo request"}, + ], + }, + "lead-002": { + "lead_id": "lead-002", + "name": "Marcus Johnson", + "email": "m.johnson@brighthealth.io", + "company": "BrightHealth", + "industry": "healthcare", + "deal_value": 120000, + "status": "dormant", + "last_contact": "2025-06-20", + "created": "2025-02-14", + "activities": [], + }, +} + + +# ── Tool implementations ──────────────────────────────────────────────────── + + +def search_leads(input_data): + results = [] + status = input_data.get("status") + industry = input_data.get("industry") + limit = input_data.get("limit", 10) + + for lead in LEADS.values(): + if status and lead["status"] != status: + continue + if industry and lead["industry"] != industry: + continue + results.append({ + "lead_id": lead["lead_id"], + "name": lead["name"], + "company": lead["company"], + "status": lead["status"], + }) + + return {"leads": results[:limit], "count": len(results[:limit])} + + +def get_lead_details(input_data): + lead = LEADS.get(input_data.get("lead_id", "")) + if not lead: + return {"error": f"Lead '{input_data.get('lead_id')}' not found"} + return deepcopy(lead) + + +def update_lead_status(input_data): + lead = LEADS.get(input_data.get("lead_id", "")) + if not lead: + return {"error": f"Lead '{input_data.get('lead_id')}' not found"} + old_status = lead["status"] + lead["status"] = input_data.get("status", "") + return {"lead_id": lead["lead_id"], "old_status": old_status, "new_status": lead["status"]} + + +def log_activity(input_data): + lead = LEADS.get(input_data.get("lead_id", "")) + if not lead: + return {"error": f"Lead '{input_data.get('lead_id')}' not found"} + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + activity = {"date": today, "type": input_data.get("type", "note"), "summary": input_data.get("summary", "")} + lead["activities"].append(activity) + lead["last_contact"] = today + return {"lead_id": lead["lead_id"], "activity": activity} + + +def draft_followup_email(input_data): + lead = LEADS.get(input_data.get("lead_id", "")) + if not lead: + return {"error": f"Lead '{input_data.get('lead_id')}' not found"} + return { + "to": lead["email"], + "name": lead["name"], + "company": lead["company"], + "reason": input_data.get("reason", "general follow-up"), + } + + +TOOLS = { + "search_leads": search_leads, + "get_lead_details": get_lead_details, + "update_lead_status": update_lead_status, + "log_activity": log_activity, + "draft_followup_email": draft_followup_email, +} + + +# ── Process function (the only thing the SDK needs) ───────────────────────── + + +def process(key, value, ctx): + request = json.loads(value) + tool_name = request.get("name", "") + + handler = TOOLS.get(tool_name) + if handler is None: + ctx.error(f"Unknown tool: {tool_name}") + return + + try: + result = handler(request.get("input", {})) + ctx.success(result) + except Exception as e: + ctx.error(str(e)) + + +# ── Entry point ───────────────────────────────────────────────────────────── + +if __name__ == "__main__": + runner = ToolConsumerRunner( + ToolConsumerConfig( + agent_name=os.environ["AGENT_NAME"], + brokers=os.environ.get("KAFKA_BOOTSTRAP_SERVERS", "kafka:29092"), + process_fn=process, + ) + ) + runner.start() diff --git a/examples/lead-followup-agent-confluent-cloud/lead-tool-service/requirements.txt b/examples/lead-followup-agent-confluent-cloud/lead-tool-service/requirements.txt new file mode 100644 index 0000000..f79e77a --- /dev/null +++ b/examples/lead-followup-agent-confluent-cloud/lead-tool-service/requirements.txt @@ -0,0 +1 @@ +confluent-kafka>=2.6.0 diff --git a/examples/lead-followup-agent-confluent-cloud/lead-tool-service/test_app_sdk.py b/examples/lead-followup-agent-confluent-cloud/lead-tool-service/test_app_sdk.py new file mode 100644 index 0000000..4cba5d1 --- /dev/null +++ b/examples/lead-followup-agent-confluent-cloud/lead-tool-service/test_app_sdk.py @@ -0,0 +1,181 @@ +""" +Tests that the lead-followup-agent tool functions work correctly +through the flightdeck SDK's success()/error() flow. + +No Kafka required — we mock the consumer/producers and invoke +the process function directly, just like the SDK runner would. +""" + +import json +import pytest +from unittest.mock import MagicMock +from flightdeck_sdk.message_context import KafkaMessageContext + +# Import the tool functions and process handler from the SDK-based app +from app import process, LEADS + + +def make_ctx(tool_use_id="toolu_test", key="session-1", value="{}"): + consumer = MagicMock() + output_producer = MagicMock() + dlq_producer = MagicMock() + + try: + incoming = json.loads(value) if value else {} + except (json.JSONDecodeError, TypeError): + incoming = {} + + ctx = KafkaMessageContext( + consumer=consumer, + output_producer=output_producer, + dlq_producer=dlq_producer, + output_topic="test-agent-tool-use-result", + dlq_topic="test-agent-tool-use-dlq", + topic="test-agent-tool-use", + partition=0, + offset=0, + key=key, + value=value, + tool_use_id=tool_use_id, + incoming=incoming, + ) + return ctx, output_producer, dlq_producer + + +def get_success_result(producer): + payload = json.loads(producer.produce.call_args.kwargs["value"]) + return payload + + +class TestSearchLeads: + def test_search_by_status(self): + value = json.dumps({"name": "search_leads", "input": {"status": "dormant"}}) + ctx, producer, _ = make_ctx(value=value) + + process("session-1", value, ctx) + + assert ctx.settled is True + payload = get_success_result(producer) + assert payload["result"]["count"] > 0 + for lead in payload["result"]["leads"]: + assert lead["status"] == "dormant" + + def test_search_by_industry(self): + value = json.dumps({"name": "search_leads", "input": {"industry": "healthcare"}}) + ctx, producer, _ = make_ctx(value=value) + + process("session-1", value, ctx) + + payload = get_success_result(producer) + assert payload["result"]["count"] == 1 + assert payload["result"]["leads"][0]["company"] == "BrightHealth" + + def test_search_no_results(self): + value = json.dumps({"name": "search_leads", "input": {"industry": "aerospace"}}) + ctx, producer, _ = make_ctx(value=value) + + process("session-1", value, ctx) + + payload = get_success_result(producer) + assert payload["result"]["count"] == 0 + assert payload["result"]["leads"] == [] + + +class TestGetLeadDetails: + def test_existing_lead(self): + value = json.dumps({"name": "get_lead_details", "input": {"lead_id": "lead-001"}}) + ctx, producer, _ = make_ctx(value=value) + + process("session-1", value, ctx) + + payload = get_success_result(producer) + assert payload["result"]["name"] == "Jane Chen" + assert payload["result"]["company"] == "Acme Corp" + assert "activities" in payload["result"] + + def test_missing_lead(self): + value = json.dumps({"name": "get_lead_details", "input": {"lead_id": "nonexistent"}}) + ctx, producer, _ = make_ctx(value=value) + + process("session-1", value, ctx) + + # Still success — the tool returns an error in content, not a framework error + payload = get_success_result(producer) + assert "not found" in payload["result"]["error"] + + +class TestUpdateLeadStatus: + def test_updates_status_and_returns_old_and_new(self): + # Reset state + LEADS["lead-001"]["status"] = "dormant" + + value = json.dumps({"name": "update_lead_status", "input": {"lead_id": "lead-001", "status": "contacted"}}) + ctx, producer, _ = make_ctx(value=value) + + process("session-1", value, ctx) + + payload = get_success_result(producer) + assert payload["result"]["old_status"] == "dormant" + assert payload["result"]["new_status"] == "contacted" + assert LEADS["lead-001"]["status"] == "contacted" + + # Cleanup + LEADS["lead-001"]["status"] = "dormant" + + +class TestLogActivity: + def test_appends_activity(self): + original_count = len(LEADS["lead-002"]["activities"]) + + value = json.dumps({"name": "log_activity", "input": { + "lead_id": "lead-002", "type": "call", "summary": "Follow-up call" + }}) + ctx, producer, _ = make_ctx(value=value) + + process("session-1", value, ctx) + + payload = get_success_result(producer) + assert payload["result"]["activity"]["type"] == "call" + assert payload["result"]["activity"]["summary"] == "Follow-up call" + assert len(LEADS["lead-002"]["activities"]) == original_count + 1 + + # Cleanup + LEADS["lead-002"]["activities"].pop() + + +class TestDraftFollowupEmail: + def test_returns_email_context(self): + value = json.dumps({"name": "draft_followup_email", "input": { + "lead_id": "lead-001", "reason": "new pricing" + }}) + ctx, producer, _ = make_ctx(value=value) + + process("session-1", value, ctx) + + payload = get_success_result(producer) + assert payload["result"]["to"] == "jane.chen@acmecorp.com" + assert payload["result"]["reason"] == "new pricing" + + +class TestErrorHandling: + def test_unknown_tool_sends_to_dlq(self): + value = json.dumps({"name": "nonexistent_tool", "input": {}}) + ctx, producer, dlq_producer = make_ctx(value=value) + + process("session-1", value, ctx) + + # Should error (DLQ), not success + assert ctx.settled is True + producer.produce.assert_not_called() + dlq_producer.produce.assert_called_once() + headers = dict(dlq_producer.produce.call_args.kwargs["headers"]) + assert b"Unknown tool" in headers["error.reason"] + + def test_tool_use_id_attached_to_success(self): + value = json.dumps({"name": "search_leads", "input": {}}) + ctx, producer, _ = make_ctx(tool_use_id="toolu_abc123", value=value) + + process("session-1", value, ctx) + + payload = get_success_result(producer) + assert payload["tool_use_id"] == "toolu_abc123" diff --git a/examples/lead-followup-agent-confluent-cloud/system-prompt.txt b/examples/lead-followup-agent-confluent-cloud/system-prompt.txt new file mode 100644 index 0000000..caf9c07 --- /dev/null +++ b/examples/lead-followup-agent-confluent-cloud/system-prompt.txt @@ -0,0 +1,15 @@ +You are a sales follow-up assistant that helps re-engage dormant leads. You have direct access to the CRM through your tools. + +WORKFLOW: +1. When asked to find old leads, use search_leads to find them. Present results as a numbered list with key details (name, company, last contact date, status). +2. When the user picks leads to follow up with, use get_lead_details to review their full history and mark them as pending_followup using update_lead_status. +3. Draft a personalized follow-up email using draft_followup_email. Present the draft to the user for review. +4. WAIT for the user to approve the email before proceeding. Do NOT log activity or update status until the user says to send it. +5. After the user approves, use log_activity to record what was sent and update the lead status to contacted. + +RULES: +- ALWAYS use your tools to look up lead data. Never make up contact details or history. +- The ONLY action that requires user approval is sending an email. Searching, looking up details, and updating status can be done freely. +- Use log_activity after every action so the CRM stays up to date. +- If the user asks about pending work, use search_leads with status=pending_followup to find leads that were approved but not yet contacted. +- Be concise. Present data in tables when showing multiple leads. \ No newline at end of file diff --git a/examples/lead-followup-agent-confluent-cloud/tools.json b/examples/lead-followup-agent-confluent-cloud/tools.json new file mode 100644 index 0000000..ad28229 --- /dev/null +++ b/examples/lead-followup-agent-confluent-cloud/tools.json @@ -0,0 +1,112 @@ +[ + { + "name": "search_leads", + "description": "Search for leads by criteria such as last contact date, status, industry, or company name. Returns a list of matching leads with summary info.", + "input_schema": { + "type": "object", + "properties": { + "last_contact_before": { + "type": "string", + "description": "ISO 8601 date — find leads not contacted since this date (e.g. 2025-06-01)" + }, + "status": { + "type": "string", + "enum": ["new", "contacted", "qualified", "proposal", "won", "lost", "dormant", "pending_followup"], + "description": "Filter by lead status" + }, + "industry": { + "type": "string", + "description": "Filter by industry (e.g. fintech, healthcare, ecommerce)" + }, + "company": { + "type": "string", + "description": "Search by company name (partial match)" + }, + "limit": { + "type": "integer", + "description": "Max number of results to return (default 10)" + } + }, + "required": [] + } + }, + { + "name": "get_lead_details", + "description": "Get full details for a specific lead including contact info, company, deal value, current status, and complete activity history (calls, emails, meetings, notes).", + "input_schema": { + "type": "object", + "properties": { + "lead_id": { + "type": "string", + "description": "The unique lead ID" + } + }, + "required": ["lead_id"] + } + }, + { + "name": "update_lead_status", + "description": "Update the status of a lead. Use 'pending_followup' when the user approves a lead for follow-up. Always log an activity explaining why the status changed.", + "input_schema": { + "type": "object", + "properties": { + "lead_id": { + "type": "string", + "description": "The unique lead ID" + }, + "status": { + "type": "string", + "enum": ["new", "contacted", "qualified", "proposal", "won", "lost", "dormant", "pending_followup"], + "description": "The new status" + } + }, + "required": ["lead_id", "status"] + } + }, + { + "name": "log_activity", + "description": "Record an activity or interaction for a lead. Use this every time you take an action — sending an email, making a call, scheduling a meeting, or adding notes.", + "input_schema": { + "type": "object", + "properties": { + "lead_id": { + "type": "string", + "description": "The unique lead ID" + }, + "type": { + "type": "string", + "enum": ["email", "call", "meeting", "note"], + "description": "Type of activity" + }, + "summary": { + "type": "string", + "description": "Brief description of what was done or discussed" + } + }, + "required": ["lead_id", "type", "summary"] + } + }, + { + "name": "draft_followup_email", + "description": "Generate a personalized follow-up email for a lead based on their history and the reason for re-engagement. Returns the draft for review before sending.", + "input_schema": { + "type": "object", + "properties": { + "lead_id": { + "type": "string", + "description": "The unique lead ID" + }, + "reason": { + "type": "string", + "description": "Why you are re-engaging this lead (e.g. new product launch, pricing change, time-based follow-up)" + }, + "tone": { + "type": "string", + "enum": ["casual", "professional", "urgent"], + "description": "Tone of the email (default: professional)" + } + }, + "required": ["lead_id", "reason"] + } + } +] From 47464cc8aa9d5fd141be57b68f32628c9ab5397d Mon Sep 17 00:00:00 2001 From: tsuz <6927131+tsuz@users.noreply.github.com> Date: Sat, 23 May 2026 14:07:05 +0900 Subject: [PATCH 2/2] use remote version --- .../docker-compose.yml | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/examples/lead-followup-agent-confluent-cloud/docker-compose.yml b/examples/lead-followup-agent-confluent-cloud/docker-compose.yml index 7eec533..784d702 100644 --- a/examples/lead-followup-agent-confluent-cloud/docker-compose.yml +++ b/examples/lead-followup-agent-confluent-cloud/docker-compose.yml @@ -1,10 +1,9 @@ services: - # ─── Application Services (built from local source) ───────────────────────── + # ─── Application Services (pulled from ghcr.io) ───────────────────────────── api: - build: - context: ../../api/chat-api + image: ghcr.io/tsuz/flightdeck/chat-api:0.0.2 ports: - "8002:8000" - "8003:8001" @@ -19,8 +18,7 @@ services: KAFKA_SASL_JAAS_CONFIG: 'org.apache.kafka.common.security.plain.PlainLoginModule required username="${CONFLUENT_API_KEY}" password="${CONFLUENT_API_SECRET}";' processing: - build: - context: ../../processor-apps/processing + image: ghcr.io/tsuz/flightdeck/processing:0.0.2 environment: AGENT_NAME: ${AGENT_NAME:-lead-followup} MEMOIR_ENABLED: "false" @@ -33,8 +31,7 @@ services: KAFKA_REPLICATION_FACTOR: "3" think-consumer: - build: - context: ../../think/think-consumer + image: ghcr.io/tsuz/flightdeck/think-consumer:0.0.2 volumes: - ./tools.json:/app/tools.json:ro - ./system-prompt.txt:/app/system-prompt.txt:ro @@ -70,8 +67,7 @@ services: # ─── Frontend ──────────────────────────────────────────────────────────────── frontend: - build: - context: ../../frontend + image: ghcr.io/tsuz/flightdeck/frontend:0.0.2 ports: - "8080:80" depends_on: