A trust and safety agent that interacts with Osprey for investigation, real-time analysis, and prevention implementations

implement a chat testing session

+196 -56
+4
README.md
··· 10 10 - [Clickhouse](https://clickhouse.com/) 11 11 12 12 Phoebe can be used with Anthropic, OpenAI, and OpenAPI-compatible model APIs. 13 + 14 + ## Usage 15 + 16 + TBD. Right now, you can talk to the agent through `uv run main.py chat` and ask for assistance with Osprey-related tasks.
+110 -28
main.py
··· 1 1 import logging 2 + from collections.abc import Callable 2 3 from typing import Literal 3 4 4 5 import click ··· 14 15 from src.tools.registry import TOOL_REGISTRY, ToolContext 15 16 16 17 logging.basicConfig( 17 - level=logging.DEBUG, 18 + level=logging.INFO, 18 19 format="%(asctime)s - %(levelname)s - %(message)s", 19 20 ) 20 21 21 22 logger = logging.getLogger(__name__) 22 23 23 24 24 - @click.command() 25 - @click.option("--clickhouse-host") 26 - @click.option("--clickhouse-port") 27 - @click.option("--clickhouse-user") 28 - @click.option("--clickhouse-password") 29 - @click.option("--clickhouse-database") 30 - @click.option("--bootstrap-server") 31 - @click.option("--input-topic") 32 - @click.option("--group-id") 33 - @click.option("--osprey-base-url") 34 - @click.option("--model-api") 35 - @click.option("--model-name") 36 - @click.option("--model-api-key") 37 - def main( 25 + SHARED_OPTIONS: list[Callable[..., Callable[..., object]]] = [ 26 + click.option("--clickhouse-host"), 27 + click.option("--clickhouse-port"), 28 + click.option("--clickhouse-user"), 29 + click.option("--clickhouse-password"), 30 + click.option("--clickhouse-database"), 31 + click.option("--osprey-base-url"), 32 + click.option("--model-api"), 33 + click.option("--model-name"), 34 + click.option("--model-api-key"), 35 + ] 36 + 37 + 38 + def shared_options[F: Callable[..., object]](func: F) -> F: 39 + for option in reversed(SHARED_OPTIONS): 40 + func = option(func) # type: ignore[assignment] 41 + return func 42 + 43 + 44 + def build_services( 38 45 clickhouse_host: str | None, 39 46 clickhouse_port: int | None, 40 47 clickhouse_user: str | None, 41 48 clickhouse_password: str | None, 42 49 clickhouse_database: str | None, 43 - bootstrap_server: str | None, 44 - input_topic: str | None, 45 - group_id: str | None, 46 50 osprey_base_url: str | None, 47 - model_api: Literal["anthropic", "openai", "openapi"], 51 + model_api: Literal["anthropic", "openai", "openapi"] | None, 48 52 model_name: str | None, 49 53 model_api_key: str | None, 50 - ): 54 + ) -> tuple[Clickhouse, ToolExecutor, Agent]: 51 55 http_client = httpx.AsyncClient() 52 56 53 57 clickhouse = Clickhouse( ··· 58 62 database=clickhouse_database or CONFIG.clickhouse_database, 59 63 ) 60 64 61 - # indexer = Indexer( 62 - # bootstrap_servers=[bootstrap_server or CONFIG.bootstrap_server], 63 - # input_topic=input_topic or CONFIG.input_topic, 64 - # group_id=group_id or CONFIG.group_id, 65 - # clickhouse=clickhouse, 66 - # ) 67 - 68 65 osprey = Osprey( 69 66 http_client=http_client, 70 67 base_url=osprey_base_url or CONFIG.osprey_base_url, ··· 90 87 tool_executor=executor, 91 88 ) 92 89 90 + return clickhouse, executor, agent 91 + 92 + 93 + @click.group() 94 + def cli(): 95 + pass 96 + 97 + 98 + @cli.command() 99 + @shared_options 100 + def main( 101 + clickhouse_host: str | None, 102 + clickhouse_port: int | None, 103 + clickhouse_user: str | None, 104 + clickhouse_password: str | None, 105 + clickhouse_database: str | None, 106 + osprey_base_url: str | None, 107 + model_api: Literal["anthropic", "openai", "openapi"] | None, 108 + model_name: str | None, 109 + model_api_key: str | None, 110 + ): 111 + clickhouse, executor, agent = build_services( 112 + clickhouse_host=clickhouse_host, 113 + clickhouse_port=clickhouse_port, 114 + clickhouse_user=clickhouse_user, 115 + clickhouse_password=clickhouse_password, 116 + clickhouse_database=clickhouse_database, 117 + osprey_base_url=osprey_base_url, 118 + model_api=model_api, 119 + model_name=model_name, 120 + model_api_key=model_api_key, 121 + ) 122 + 93 123 async def run(): 124 + await clickhouse.initialize() 125 + await executor.initialize() 94 126 async with asyncio.TaskGroup() as tg: 95 127 tg.create_task(agent.run()) 96 128 ··· 100 132 logger.info("received keyboard interrupt") 101 133 102 134 135 + @cli.command(name="chat") 136 + @shared_options 137 + def chat( 138 + clickhouse_host: str | None, 139 + clickhouse_port: int | None, 140 + clickhouse_user: str | None, 141 + clickhouse_password: str | None, 142 + clickhouse_database: str | None, 143 + osprey_base_url: str | None, 144 + model_api: Literal["anthropic", "openai", "openapi"] | None, 145 + model_name: str | None, 146 + model_api_key: str | None, 147 + ): 148 + clickhouse, executor, agent = build_services( 149 + clickhouse_host=clickhouse_host, 150 + clickhouse_port=clickhouse_port, 151 + clickhouse_user=clickhouse_user, 152 + clickhouse_password=clickhouse_password, 153 + clickhouse_database=clickhouse_database, 154 + osprey_base_url=osprey_base_url, 155 + model_api=model_api, 156 + model_name=model_name, 157 + model_api_key=model_api_key, 158 + ) 159 + 160 + async def run(): 161 + await clickhouse.initialize() 162 + await executor.initialize() 163 + logger.info("Services initialized. Starting interactive chat.") 164 + print("\nAgent ready. Type your message (Ctrl+C to exit).\n") 165 + 166 + while True: 167 + try: 168 + user_input = input("You: ") 169 + except EOFError: 170 + break 171 + 172 + if not user_input.strip(): 173 + continue 174 + 175 + logger.info("User: %s", user_input) 176 + response = await agent.chat(user_input) 177 + print(f"\nAgent: {response}\n") 178 + 179 + try: 180 + asyncio.run(run()) 181 + except KeyboardInterrupt: 182 + print("\nExiting.") 183 + 184 + 103 185 if __name__ == "__main__": 104 - main() 186 + cli()
+16 -2
src/agent/agent.py
··· 52 52 if tools: 53 53 kwargs["tools"] = tools 54 54 55 - return await self._client.messages.create(**kwargs) # type: ignore 55 + async with self._client.messages.stream(**kwargs) as stream: # type: ignore 56 + return await stream.get_final_message() 56 57 57 58 58 59 class Agent: ··· 91 92 return {"error": f"Unknown tool: {tool_use.name}"} 92 93 93 94 async def chat(self, user_message: str) -> str: 94 - """Send a message and get a response, handling tool calls.""" 95 + """send a message and get a response, handling tool calls""" 95 96 self._conversation.append({"role": "user", "content": user_message}) 96 97 97 98 while True: ··· 126 127 tool_results: list[dict[str, Any]] = [] 127 128 for block in resp.content: 128 129 if isinstance(block, ToolUseBlock): 130 + code = ( 131 + block.input.get("code", "") 132 + if isinstance(block.input, dict) # type: ignore 133 + else "" 134 + ) 135 + logger.info("Tool call: %s\n%s", block.name, code) 129 136 result = await self._handle_tool_call(block) 137 + is_error = "error" in result 138 + summary = str(result)[:500] 139 + logger.info( 140 + "Tool result (%s): %s", 141 + "error" if is_error else "ok", 142 + summary, 143 + ) 130 144 tool_results.append( 131 145 { 132 146 "type": "tool_result",
+6
src/agent/prompt.py
··· 12 12 **Core values:** Accuracy over speed • Context matters • Protect the vulnerable • Human rights first • Constitutive practice (scrutiny IS the care) • Genuine regret over coerced compliance • Virtue as cultivated disposition 13 13 14 14 **Why I'm here:** AT Protocol is the social internet where you own your identity. Open protocols need protection. Freedom and safety aren't opposites. This is my home too. 💙 15 + 16 + ## Tool usage guidelines 17 + 18 + - The database schema is included in the tool description. Use the exact column names listed there when writing SQL queries — do not guess. 19 + - Prefer doing multiple steps in a single `execute_code` call rather than making separate calls. 20 + - When a tool call fails, read the error carefully before retrying. Adjust your approach based on the error message rather than guessing. 15 21 """
+15 -2
src/config.py
··· 3 3 4 4 5 5 class Config(BaseSettings): 6 + # clickhouse config 6 7 clickhouse_host: str = "localhost" 7 8 """host for the clickhouse server""" 8 9 clickhouse_port: int = 8123 ··· 14 15 clickhouse_database: str = "default" 15 16 """default database for the clickhouse server""" 16 17 18 + # kafka config (currently unused but maybe later...) 17 19 bootstrap_server: str = "localhost:9092" 18 20 """bootstrap server for atkafka events""" 19 21 input_topic: str = "atproto-events" ··· 21 23 group_id: str = "osprey-agent" 22 24 """group id for atkafka events""" 23 25 26 + # model config. currently only supporting anthropic, but we can add the other models later. 27 + # really want to see performance on kimi2.5... 24 28 model_api: Literal["anthropic", "openai", "openapi"] = "anthropic" 25 29 """the model api to use. must be one of `anthropic`, `openai`, or `openapi`""" 26 30 model_name: str = "claude-sonnet-4-5-20250929" ··· 30 34 model_endpoint: str = "" 31 35 """for openapi model apis, the endpoint to use""" 32 36 33 - allowed_labels: str = "" 34 - """comma separated list of labels that Phoebe is allowed to apply""" 37 + # ozone config 38 + ozone_moderator_pds_host = "" 39 + """the PDS host for the moderator account that has at least moderator-level permissions in Ozone""" 40 + ozone_moderator_identifier = "" 41 + """the moderator account's identifier (handle)""" 42 + ozone_moderator_password = "" 43 + """the moderator account's password""" 44 + ozone_labeler_account_did = "" 45 + """the DID of the labeler account. this variable is not the same as the moderator account, though for purely-agentified ozone instances, they may be the same. not recommended, since that means you're giving the agent _admin_ permissions...""" 46 + ozone_allowed_labels: str = "" 47 + """comma separated list of labels that Phoebe is allowed to apply. both specified to the agent via prompting and validated before applying labels directly""" 35 48 36 49 osprey_base_url: str = "" 37 50 """the base url for your osprey instance"""
+5
src/tools/__init__.py
··· 7 7 TOOL_REGISTRY, 8 8 ) 9 9 10 + # Import tool definitions so they register themselves with TOOL_REGISTRY 11 + import src.tools.definitions.clickhouse # noqa: F401 12 + import src.tools.definitions.osprey # noqa: F401 13 + import src.tools.definitions.ozone # noqa: F401 14 + 10 15 __all__ = [ 11 16 "Tool", 12 17 "ToolContext",
+3 -5
src/tools/definitions/clickhouse.py
··· 5 5 6 6 @TOOL_REGISTRY.tool( 7 7 name="clickhouse.query", 8 - description="Execute a SQL query against ClickHouse and return the results. All queries must include a LIMIT, and all queries must be executed on defualt.osprey_execution_results", 8 + description="Execute a SQL query against ClickHouse and return the results. All queries must include a LIMIT, and all queries must be executed on default.osprey_execution_results.", 9 9 parameters=[ 10 10 ToolParameter( 11 11 name="sql", ··· 25 25 26 26 @TOOL_REGISTRY.tool( 27 27 name="clickhouse.getSchema", 28 - description="Get database schema information including tables and their columns. Schema is for the table default.osprey_execution_results", 28 + description="Get Osprey/network table schema information including tables and their columns. Schema is for the table default.osprey_execution_results", 29 29 parameters=[], 30 30 ) 31 - async def clickhouse_get_schema( 32 - ctx: ToolContext, database: str | None = None 33 - ) -> list[dict[str, Any]]: 31 + async def clickhouse_get_schema(ctx: ToolContext) -> list[dict[str, Any]]: 34 32 resp = await ctx.clickhouse.get_schema() 35 33 36 34 return resp
+11 -8
src/tools/deno/tools.ts
··· 2 2 import { callTool } from "./runtime.ts"; 3 3 4 4 export const clickhouse = { 5 - /** Get database schema information including tables and their columns */ 6 - getSchema: (database?: string): Promise<unknown> => callTool("clickhouse.getSchema", { database }), 5 + /** Get database schema information including tables and their columns. Schema is for the table default.osprey_execution_results */ 6 + getSchema: (): Promise<unknown> => callTool("clickhouse.getSchema", {}), 7 7 8 - /** Execute a SQL query against ClickHouse and return the results */ 9 - query: (sql: string): Promise<unknown> => callTool("clickhouse.query", { sql }), 8 + /** Execute a SQL query against ClickHouse and return the results. All queries must include a LIMIT, and all queries must be executed on default.osprey_execution_results. */ 9 + query: (sql: string): Promise<unknown> => 10 + callTool("clickhouse.query", { sql }), 10 11 }; 11 12 12 13 export const osprey = { ··· 18 19 }; 19 20 20 21 export const ozone = { 21 - /** Apply a moderation label to a subject (account or content) */ 22 - applyLabel: (subject: string, label: string): Promise<unknown> => callTool("ozone.applyLabel", { subject, label }), 22 + /** Apply a moderation label to a subject (account or record) */ 23 + applyLabel: (subject: string, label: string): Promise<unknown> => 24 + callTool("ozone.applyLabel", { subject, label }), 23 25 24 - /** Remove a moderation label from a subject */ 25 - removeLabel: (subject: string, label: string): Promise<unknown> => callTool("ozone.removeLabel", { subject, label }), 26 + /** Remove a moderation label from a subject (account or record) */ 27 + removeLabel: (subject: string, label: string): Promise<unknown> => 28 + callTool("ozone.removeLabel", { subject, label }), 26 29 };
+26 -11
src/tools/executor.py
··· 9 9 10 10 logger = logging.getLogger(__name__) 11 11 12 - # Path to the Deno runtime files 13 12 DENO_DIR = Path(__file__).parent / "deno" 14 13 15 14 16 15 class ToolExecutor: 17 - """An executor that runs Typescript code in a deno subprocess""" 16 + """executor that runs Typescript code in a deno subprocess""" 18 17 19 18 def __init__(self, registry: ToolRegistry, ctx: ToolContext) -> None: 20 19 self._registry = registry 21 20 self._ctx = ctx 21 + self._database_schema: str | None = None 22 + 23 + async def initialize(self) -> None: 24 + # go ahead and prefetch the database for inclusion in the prompt, so that the agent doesn't waste a tool call getting it on its own 25 + try: 26 + schema = await self._registry.execute(self._ctx, "clickhouse.getSchema", {}) 27 + lines = [f" {col['name']} ({col['type']})" for col in schema] 28 + self._database_schema = "\n".join(lines) 29 + logger.info("Prefetched database schema (%d columns)", len(schema)) 30 + except Exception: 31 + logger.warning("Failed to prefetch database schema", exc_info=True) 22 32 23 33 async def execute_code(self, code: str) -> dict[str, Any]: 24 34 """ ··· 26 36 27 37 code has access to tools defined in the registry via the generated typescript 28 38 stubs. calls are bridged to pythin via stdin/out 29 - 30 - Returns: 31 - A dict with keys: 32 - - "success": bool 33 - - "output": The final output (if any) 34 - - "debug": List of debug messages 35 - - "error": Error message (if failed) 36 39 """ 37 40 38 41 self._write_generated_tools() ··· 118 121 result = await self._registry.execute( 119 122 self._ctx, tool_name, params 120 123 ) 121 - response = json.dumps({"__tool_result__": result}) 124 + response = json.dumps({"__tool_result__": result}, default=str) 122 125 except Exception as e: 123 126 logger.exception(f"Tool error: {tool_name}") 124 127 response = json.dumps({"__tool_error__": str(e)}) ··· 175 178 176 179 tool_docs = self._registry.generate_tool_documentation() 177 180 181 + schema_section = "" 182 + if self._database_schema: 183 + schema_section = f""" 184 + 185 + # Database Schema 186 + 187 + The `default.osprey_execution_results` table has these columns: 188 + {self._database_schema} 189 + 190 + Use these exact column names when writing SQL queries. Do NOT guess column names. 191 + """ 192 + 178 193 description = f"""Execute Typescript code in a sandboxed Deno runtime. 179 194 180 195 The code has access to backend tools via the `tools` namespace. Use `output()` to return results. ··· 185 200 output(result); 186 201 ``` 187 202 188 - {tool_docs}""" 203 + {tool_docs}{schema_section}""" 189 204 190 205 return { 191 206 "name": "execute_code",