diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..3975928 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,191 @@ +# ConnectChain — Architecture Guide + +--- + +## Table of Contents + +1. [High-Level Overview](#1-high-level-overview) +2. [Module Map](#2-module-map) +3. [LangChain Dependency Chain](#3-langchain-dependency-chain) +4. [Session & Auth Lifecycle](#4-session--auth-lifecycle) +5. [MCP Integration Layer](#5-mcp-integration-layer) +6. [Upstream Risk Surface](#6-upstream-risk-surface) +7. [Bug History](#7-bug-history) + +--- + +## 1. High-Level Overview + +ConnectChain is an **enterprise adapter layer** that sits between application code and LangChain, adding: + +- **Enterprise Auth Service (EAS)** JWT injection at the model level +- **Outbound proxy** support per-model via config +- **Prompt sanitization hooks** (`ValidPromptTemplate`, `ValidLLMChain`) +- **Portable orchestration** (model-provider-agnostic chain execution) +- **MCP tool integration** (Model Context Protocol via `connectchain.tools.mcp`) + +``` +┌────────────────────────────────────────────────────────┐ +│ Application Code │ +└────────────────────────────┬───────────────────────────┘ + │ +┌────────────────────────────▼───────────────────────────┐ +│ ConnectChain │ +│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │ +│ │ lcel/ │ │ orchestrators│ │ prompts/ │ │ +│ │ model() │ │ Portable │ │ ValidPrompt │ │ +│ └──────┬──────┘ │ Orchestrator │ │ Template │ │ +│ │ └──────┬───────┘ └───────┬───────┘ │ +│ ┌──────▼──────────────────────────────────▼───────┐ │ +│ │ chains/ValidLLMChain │ │ +│ └──────────────────────┬───────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────▼───────────────────────────┐ │ +│ │ utils/ (EAS token, proxy, config) │ │ +│ └──────────────────────┬───────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────▼───────────────────────────┐ │ +│ │ tools/mcp (MCPToolAgent) │ │ +│ └──────────────────────────────────────────────────┘ │ +└────────────────────────┬───────────────────────────────┘ + │ +┌────────────────────────▼─────────────────────────────┐ +│ LangChain │ +│ LLMChain · PromptTemplate · ChatOpenAI · Callbacks │ +└───────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Module Map + +| Module | Key Classes / Functions | Responsibility | +|--------|------------------------|----------------| +| `connectchain.lcel` | `model()`, `LCELLogger`/`PrintLogger`, `LCELRetry` | LCEL-compatible model factory; logging and retry hooks | +| `connectchain.orchestrators` | `PortableOrchestrator` | Provider-agnostic chain runner | +| `connectchain.prompts` | `ValidPromptTemplate` | Pre-send prompt sanitization | +| `connectchain.chains` | `ValidLLMChain` | Post-response output sanitization, applied on `run()`/`arun()`/`invoke()`/`ainvoke()` | +| `connectchain.utils` | `Config`, `get_token_from_env()`, `SessionMap`, `TokenUtil` | Config loading, EAS JWT retrieval, session expiry cache | +| `connectchain.tools.mcp` | `MCPToolAgent`, `MCPToolLoader` | MCP server integration (fully async) | + +`connectchain/config/` contains only `example.config.yml` — there is no `connectchain.config` Python module; config parsing lives in `connectchain.utils.config` (`Config`, `ConfigWrapper`). + +--- + +## 3. LangChain Dependency Chain + +ConnectChain currently imports from LangChain in one critical path: + +``` +connectchain.chains.ValidLLMChain + └── inherits langchain.chains.llm.LLMChain ← deprecated since 0.1.0, removed in LangChain 1.0 + (per LLMChain's own LangChainDeprecationWarning) +``` + +`pyproject.toml` pins `langchain<0.4.0` specifically because LangChain's 1.x line removes +`langchain.chains`/`langchain.schema`/`langchain.llms` entirely, which this codebase imports +throughout — an unbounded dependency range breaks the package outright on a fresh install. + +`PortableOrchestrator` uses the LCEL `.invoke()`/`.ainvoke()` API (not the deprecated +`.run()`/`.arun()`), and token injection is a plain constructor argument +(`ChatOpenAI(api_key=SecretStr(auth_token), ...)` in `connectchain/lcel/model.py`) — there is no +monkey-patching involved in getting the token into the model client. (`connectchain.utils.proxy_manager` +does monkey-patch `requests.Session.__init__`, but that's for outbound proxy support, unrelated to +token injection.) + +### Migration Target (LCEL Pipes), if `ValidLLMChain` is ever rebuilt on a bare Runnable pipe + +```python +# Current (LLMChain-based, still supported through LangChain 0.3.x) +chain = ValidLLMChain(llm=llm, prompt=prompt, output_sanitizer=my_sanitizer) +result = chain.invoke({"topic": "..."}) + +# Hypothetical LCEL-native replacement, needed before/if LangChain 1.0 is adopted +from langchain_core.runnables import RunnableLambda +chain = prompt | llm | RunnableLambda(my_sanitizer) +result = chain.invoke({"topic": "..."}) +``` + +--- + +## 4. Session & Auth Lifecycle + +``` +Application calls model() (or PortableOrchestrator built on top of it) + │ + ▼ +SessionMap(config.eas.token_refresh_interval) ← singleton, per-process + │ + ▼ +get_valid_llm(session_key) ← atomic: existence + expiry check under one lock + ├── hit, not expired → return cached LLM instance + └── miss or expired → get_token_from_env() refreshes the JWT via EAS, + builds a new ChatOpenAI/AzureOpenAI with that token + as a constructor arg, caches it via new_session() + │ + ▼ + LangChain model call (sync via .invoke(), async via .ainvoke()) + │ + ▼ + [If output_sanitizer is set] applied to the response before returning +``` + +`SessionMap`'s singleton is created via double-checked locking (`_instance_lock` guards first +construction; `self._lock`, created only after construction completes, guards all `session_map` +reads/writes) — see §7 for why both locks exist. + +--- + +## 5. MCP Integration Layer + +The `connectchain.tools.mcp` module wraps the Model Context Protocol (MCP) to expose external tool servers as LangChain-compatible tools, built on `langchain-mcp-adapters`: + +``` +MCP Server (stdio) + │ + ▼ +MCPToolLoader.load_tools() ← async, returns LangChain BaseTool objects + │ + ▼ +MCPToolAgent (a langchain_core Runnable) + ├── ainvoke() ← async + └── abatch() ← async + │ + ▼ +Application code / LangChain agent framework +``` + +### Current Limitation +`MCPToolAgent` does not support multi-turn memory or LangGraph checkpointing. Each call is stateless. + +--- + +## 6. Upstream Risk Surface + +| Risk | Severity | Affected Files | Notes | +|------|----------|---------------|-----| +| `LLMChain` removed in LangChain 1.0 | 🔴 Critical | `chains/valid_llm_chain.py` (inherits it) | `pyproject.toml` pins `langchain<0.4.0` to stay ahead of this; an unbounded range breaks the install today, since `pip`/`uv` will happily resolve to 1.x | +| `langchain-mcp-adapters` version drift | 🟠 High | `tools/mcp/` | Pinned `<0.2.0` — 0.2.0 imports `langchain_core.messages.content`, which needs a newer `langchain-core` than the pinned `langchain<0.4.0` line provides | + +Track upstream changes: [langchain-ai/langchain releases](https://github.com/langchain-ai/langchain/releases) + +--- + +## 7. Bug History + +The bugs below were found across several rounds of code review (see this PR's commit history for +full detail) and fixed on this branch; kept here for context on *why* several modules look the way +they do, not as an open worklist. + +| Area | File | Root Cause (as found) | Resolution | +|------|------|-----------|-------------| +| Output sanitizer bypass (input vs. response) | `chains/valid_llm_chain.py` | `output_sanitizer` was applied to the user's input, not the LLM's response | `invoke()`/`ainvoke()` sanitize the response; `run()`/`arun()` return it as-is since they dispatch through `invoke()`/`ainvoke()` internally (see next row) | +| Output sanitizer applied twice | `chains/valid_llm_chain.py` | `run()`/`arun()` sanitized the result AND dispatched through `Chain.__call__` → `self.invoke()`/`self.ainvoke()`, which (via Python's polymorphism) already sanitized it once — e.g. `"[S:RAW]"` became `"[S:[S:RAW]]"` | `run()`/`arun()` no longer sanitize directly; they rely entirely on the `invoke()`/`ainvoke()` dispatch | +| SessionMap KeyError | `utils/session_map.py` | `is_expired()` indexed the session dict directly, raising `KeyError` for an unregistered session | Existence + expiry are now checked together (`get_valid_llm()`), returning `None`/`True` instead of raising | +| SessionMap singleton construction race | `utils/session_map.py` | `__new__`'s `cls._instance is None` check (and, in an earlier fix, the instance-attribute assignment order) had unguarded/unsafe windows under concurrent first construction | Double-checked locking via a class-level `_instance_lock`; the instance is fully built on a local variable before being published to `cls._instance` | +| Deprecated LangChain API | `orchestrators/portable_orchestrator.py` | Called `LLMChain.run()`/`.arun()`, deprecated since LangChain 0.1.0 | Now uses `.invoke()`/`.ainvoke()` | +| Broken input mapping | `orchestrators/portable_orchestrator.py` | `.invoke()`/`.ainvoke()` were called with a hardcoded `{"input": query}` dict; `Chain.prep_inputs()` only auto-maps a bare value onto the chain's real input key when the input isn't already a dict, so `"input"` had to exactly match the prompt's declared variable name (it essentially never did) — this broke every real prompt template | `query` is passed through unwrapped so `Chain.prep_inputs()` maps it correctly | +| Wrong output-key guessing | `orchestrators/portable_orchestrator.py` | Response extraction guessed `"text"` then `"output"` as dict keys instead of reading the chain's actual `output_key` | Reads `self._chain.output_key` (default `"text"`) | +| Silent exception swallowing | `lcel/model.py` | A bare `except (ImportError, ValueError, Exception)` discarded all errors during model init | Expected fallback exceptions are logged; anything else is re-raised as `LCELModelException` with the original traceback | +| Unsupported-provider check ordering | `lcel/model.py` | The API-key lookup ran before checking whether the provider was even supported, so an unsupported provider raised a misleading "API key not found" instead of "not supported" | Provider-support check now runs first | +| Wrong base class | `utils/llm_proxy_wrapper.py` | Imported `langchain.llms.BaseLLM`, which only covers legacy completion models | Uses `langchain_core.language_models.BaseLanguageModel`, which covers `BaseChatModel` too | diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md new file mode 100644 index 0000000..3b5ef62 --- /dev/null +++ b/docs/DEVELOPER_GUIDE.md @@ -0,0 +1,252 @@ +# ConnectChain Fork — Developer Guide + +> **Audience:** Engineers contributing to `wilsonhj/connectchain` +> **Prerequisites:** Python 3.11+, `uv`, basic LangChain familiarity + +--- + +## Table of Contents + +1. [Environment Setup](#1-environment-setup) +2. [Configuration Schema](#2-configuration-schema) +3. [Adding a Sanitizer](#3-adding-a-sanitizer) +4. [Writing Tests](#4-writing-tests) +5. [Debugging Tips](#5-debugging-tips) +6. [Code Style & Linting](#6-code-style--linting) +7. [Branch & PR Conventions](#7-branch--pr-conventions) + +--- + +## 1. Environment Setup + +```bash +# 1. Clone your fork +git clone https://github.com/wilsonhj/connectchain.git +cd connectchain + +# 2. Install uv (if not already installed) +curl -LsSf https://astral.sh/uv/install.sh | sh + +# 3. Install all dependencies including dev extras +uv sync --dev + +# 4. Copy and configure environment files +cp example.env .env +cp connectchain/config/example.config.yml config.yml +# Edit .env and config.yml with your API keys / EAS credentials + +# 5. Run the test suite to verify setup +make test +# All tests should pass +``` + +### Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `CONFIG_PATH` | Yes | Absolute path to your `config.yml` (read by `connectchain.utils.Config.from_env()`) | +| `OPENAI_API_KEY` | For direct access | OpenAI API key | +| *(name set by `eas.id_key`/`eas.secret_key` in config.yml)* | For EAS auth | EAS credential env vars are **not** fixed names — `config.yml`'s `eas.id_key`/`eas.secret_key` point to whichever env vars you use (see `example.env`'s `CONSUMER_ID1`/`CONSUMER_SECRET1`) | +| `REQUESTS_CA_BUNDLE` | Optional | Path to a `.crt` CA bundle for outbound TLS, see `example.env`. Outbound proxying is **not** an env var — configure it via the `proxy: {host, port}` block in `config.yml` (see the schema below) | + +--- + +## 2. Configuration Schema + +```yaml +# config.yml +models: + '1': # Model index (string key) + provider: openai # openai | azure | anthropic + type: chat # chat | completion + model_name: gpt-4o-mini + bypass_eas: false # true = direct API, skip EAS + # Optional overrides: + eas: + id_key: MY_EAS_ID + secret_key: MY_EAS_SECRET + scope: ["api://your-scope"] + proxy: + host: proxy.corp.com + port: 8080 + cert: + cert_path: /etc/ssl/certs + cert_name: corp.crt + cert_size: 2048 # optional; omitted/falsy skips the downloaded-file size check +``` + +### Direct Access (No EAS) + +Omit `eas`, `proxy`, and `cert` blocks entirely, or set `bypass_eas: true` per model. + +--- + +## 3. Adding a Sanitizer + +Sanitizers are plain Python callables: `(str) -> str`. They raise `OperationNotPermittedException` to block execution. + +### Input Sanitizer (ValidPromptTemplate) + +```python +from connectchain.prompts import ValidPromptTemplate +from connectchain.utils.exceptions import OperationNotPermittedException +import re + +def block_pii(query: str) -> str: + """Block prompts containing SSN patterns.""" + if re.search(r'\b\d{3}-\d{2}-\d{4}\b', query): + raise OperationNotPermittedException(f"PII detected in prompt: {query[:50]}...") + return query + +prompt = ValidPromptTemplate( + input_variables=["question"], + template="Answer this: {question}", + output_sanitizer=block_pii, +) +``` + +Note the `ValidPromptTemplate` constructor arg is named `output_sanitizer` too, but it validates +the *rendered prompt* before it's sent to the LLM — separate from `ValidLLMChain.output_sanitizer` +below, which validates the LLM's *response*. + +### Output Sanitizer + +```python +from connectchain.chains import ValidLLMChain + +def redact_output(response: str) -> str: + """Redact credit card numbers from LLM output.""" + return re.sub(r'\b(?:\d[ -]?){13,16}\b', '[REDACTED]', response) + +chain = ValidLLMChain(llm=llm, prompt=prompt, output_sanitizer=redact_output) +``` + +`ValidLLMChain.output_sanitizer` is applied to the LLM's response on all four dispatch paths +(`run()`, `arun()`, `invoke()`, `ainvoke()`), not to the input. If you build a `PortableOrchestrator` +via `from_prompt_template(...)`, pass `output_sanitizer=` as a kwarg there to have it forwarded to +the underlying `ValidLLMChain`. + +--- + +## 4. Writing Tests + +Tests live in `tests/unit_tests/` and `tests/integration_tests/` and use `unittest.TestCase` (not +bare pytest-style classes), matching the rest of the suite: + +```python +# tests/unit_tests/test_my_sanitizer.py +# (include the standard Apache 2.0 license header at the top of any new file -- +# copy it from an existing test file) +import unittest + +from connectchain.utils.exceptions import OperationNotPermittedException +from mymodule import block_pii + + +class TestBlockPII(unittest.TestCase): + def test_clean_input_passes(self): + result = block_pii("What is the weather?") + self.assertEqual(result, "What is the weather?") + + def test_ssn_raises(self): + with self.assertRaises(OperationNotPermittedException): + block_pii("My SSN is 123-45-6789") + + def test_partial_ssn_passes(self): + result = block_pii("Call 555-1234") + self.assertEqual(result, "Call 555-1234") +``` + +```bash +# Run just your new test +uv run pytest tests/unit_tests/test_my_sanitizer.py -v + +# Run with coverage +make test-unit-cov +``` + +--- + +## 5. Debugging Tips + +### SessionMap first-lookup behavior + +`SessionMap` is a per-process singleton. `is_expired(session_id)` and `get_valid_llm(session_id)` +both return safely (`True`/`None`) for a `session_id` that was never registered — they do not raise +`KeyError`. `get_llm(session_id)` is the one exception: it does a raw dict lookup and raises +`KeyError` if the session isn't registered, so only call it after confirming `is_expired()` is +`False` (or use `get_valid_llm()`, which does both atomically). + +### `LangChainDeprecationWarning` if you see it + +If you're on a version of this codebase or a dependency that still calls a deprecated LangChain API +directly (`Chain.run()`/`Chain.arun()`), you'll see: + +``` +LangChainDeprecationWarning: The method `Chain.run` was deprecated in langchain 0.1.0 +and will be removed in 1.0. Use :meth:`~invoke` instead. +``` + +`PortableOrchestrator` already uses `.invoke()`/`.ainvoke()`, so you shouldn't see this from +ConnectChain's own code paths on current `main` — if you do, it's worth filing an issue. + +### Verbose LangChain Logging + +```python +import langchain +langchain.debug = True # Prints full chain inputs/outputs +``` + +### Config Not Loading + +```bash +# Check env var is set +echo $CONFIG_PATH +# Should print absolute path to config.yml + +# Validate YAML syntax +python -c "import yaml; yaml.safe_load(open('config.yml'))" +``` + +--- + +## 6. Code Style & Linting + +```bash +# Lint (pylint, configured via .pylintrc) +make lint + +# Type checking +uv run mypy connectchain/ + +# Format (black) +uv run black connectchain/ tests/ +``` + +- Max line length: **100** characters, enforced by black/isort (see `pyproject.toml`); pylint's own limit is 160 (see `.pylintrc`) +- Docstrings: Google style +- Type hints: required for all public functions + +--- + +## 7. Branch & PR Conventions + +| Type | Branch Pattern | Example | +|------|---------------|------| +| Bug fix | `fix/-short-description` | `fix/bug-1-session-map-keyerror` | +| Feature | `feat/-short-description` | `feat/f1-mcp-langgraph-memory` | +| Docs | `docs/` | `docs/connectchain-architecture-guide` | +| Refactor | `refactor/` | `refactor/lcel-migration` | +| Chore | `chore/` | `chore/bump-langchain-0.3` | + +### Commit Message Format + +``` +(): + + + +Fixes # +``` + +PR titles must match the commit convention. Fill out `.github/pull_request_template.md` completely before requesting review. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..839d78b --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,208 @@ +# ConnectChain Fork — Ideas & Future Work + +> Maintained on the `wilsonhj/connectchain` fork. This lists forward-looking ideas, not commitments +> or dates — treat it as a backlog to pick from, not a schedule. For what's already been fixed, see +> `ARCHITECTURE.md`'s Bug History section. +> **Priority scale:** 🔴 High value · 🟡 Medium · 🟢 Nice-to-have / exploratory + +--- + +## Summary + +| ID | Type | Title | Priority | +|----|------|-------|----------| +| REF-1 | Refactor | Migrate `ValidLLMChain` off `LLMChain` onto a native LCEL `Runnable`, ahead of LangChain 1.0 removing `LLMChain` | 🔴 | +| F-1 | Feature | `MCPToolAgent` with LangGraph checkpointing (multi-turn memory) | 🟡 | +| F-2 | Feature | Async parallel tool dispatch via `asyncio.gather` | 🟡 | +| F-3 | Feature | `astream()` / token-streaming support in `PortableOrchestrator` | 🟡 | +| F-4 | Feature | Structured output enforcement (`with_structured_output`) | 🟡 | +| F-5 | Feature | Per-request retry + fallback model routing | 🟡 | +| F-6 | Feature | Prompt injection detection sanitizer (built-in) | 🟡 | +| F-7 | Feature | Agent team orchestration (supervisor + worker pattern) | 🟢 | +| OPT-1 | Optimization | `asyncio.Lock`-based session cache for async callers | 🟢 | +| OPT-2 | Optimization | Config hot-reload without restart | 🟢 | + +Bug fixes are tracked as GitHub issues/PRs, not in this file — see the repo's issue tracker and +`ARCHITECTURE.md` §7 for what's already been found and fixed. + +--- + +## Refactoring + +### REF-1 — Migrate `ValidLLMChain` off `LLMChain` + +**Scope:** `connectchain/chains/valid_llm_chain.py`, and anything constructing it directly. +**Why:** `ValidLLMChain` inherits `langchain.chains.llm.LLMChain`, which LangChain's own +`LangChainDeprecationWarning` says was deprecated in 0.1.0 and will be removed in LangChain 1.0. +`pyproject.toml` currently pins `langchain<0.4.0` to avoid that break; this refactor would be needed +before ever lifting that pin. + +**Sketch (illustrative, not a finished design):** + +```python +from langchain_core.runnables import RunnableLambda + +class ValidChain: + def __init__(self, llm, prompt, output_sanitizer=None): + pipe = prompt | llm + if output_sanitizer: + pipe = pipe | RunnableLambda(output_sanitizer) + self._chain = pipe + + def invoke(self, inputs: dict) -> str: + return self._chain.invoke(inputs) + + async def ainvoke(self, inputs: dict) -> str: + return await self._chain.ainvoke(inputs) +``` + +--- + +## New Features + +### F-1 — MCPToolAgent + LangGraph Checkpointing 🟡 + +**Problem:** Current `MCPToolAgent` is stateless — no memory between turns, no interrupt/resume. +**Solution:** Integrate LangGraph `StateGraph` with `MemorySaver` checkpointer. + +```python +from langgraph.graph import StateGraph +from langgraph.checkpoint.memory import MemorySaver + +class StatefulMCPAgent: + def __init__(self, tools, llm): + self.graph = self._build_graph(tools, llm) + self.checkpointer = MemorySaver() + + def run(self, query: str, thread_id: str) -> str: + config = {"configurable": {"thread_id": thread_id}} + return self.graph.invoke({"messages": [query]}, config=config) +``` + +**Real-world use case:** Multi-turn financial Q&A agent that remembers account context across a session without re-fetching. + +--- + +### F-2 — Parallel Tool Dispatch 🟡 + +**Problem:** Multi-tool agents call tools sequentially, creating latency proportional to tool count. +**Solution:** `asyncio.gather()` for independent tool calls. + +```python +async def parallel_dispatch(self, tool_calls: list[ToolCall]) -> list[ToolResult]: + tasks = [self.call_tool(tc.name, tc.args) for tc in tool_calls] + return await asyncio.gather(*tasks, return_exceptions=True) +``` + +**Expected speedup:** 3–5× for agents calling 3+ independent tools. + +--- + +### F-3 — Streaming Response (`astream()`) 🟡 + +**Problem:** `PortableOrchestrator` supports sync (`run_sync()`) and async (`run()`) calls, but not +token-by-token streaming. +**Solution:** Expose `astream()`/`stream()` for streaming responses to the application layer. + +```python +async def astream(self, input: str): + async for chunk in self._chain.astream({"input": input}): + yield chunk +``` + +--- + +### F-4 — Structured Output Enforcement 🟡 + +```python +from pydantic import BaseModel + +class TransactionSummary(BaseModel): + amount: float + currency: str + merchant: str + risk_score: float + +chain = prompt | llm.with_structured_output(TransactionSummary) +result: TransactionSummary = chain.invoke({"transaction": raw_data}) +``` + +--- + +### F-5 — Retry + Fallback Model Routing 🟡 + +```python +from langchain_core.runnables import RunnableWithFallbacks + +primary_chain = prompt | primary_llm +fallback_chain = prompt | fallback_llm + +resilient_chain = primary_chain.with_fallbacks([fallback_chain]) +``` + +--- + +### F-6 — Built-in Prompt Injection Detection 🟡 + +```python +INJECTION_PATTERNS = [ + r'ignore previous instructions', + r'disregard (all|your) (prior|previous|system)', + r'you are now (a |an )?(?!assistant)', + r'<\s*script\s*>', + r'system:\s*you are', +] + +def detect_prompt_injection(query: str) -> str: + for pattern in INJECTION_PATTERNS: + if re.search(pattern, query, re.IGNORECASE): + raise OperationNotPermittedException("Prompt injection detected") + return query +``` + +--- + +### F-7 — Agent Team Orchestration (Supervisor + Worker) 🟢 + +```python +def supervisor_node(state): + decision = supervisor_llm.invoke(state['messages']) + return Command(goto=decision.next_worker) + +graph.add_node("research_agent", research_subgraph) +graph.add_node("code_agent", code_subgraph) +graph.add_conditional_edges("supervisor", route_to_worker) +``` + +--- + +## Optimizations + +### OPT-1 — Thread-Safe Session Token Cache 🟡 + +```python +import asyncio + +class AsyncSessionMap: + def __init__(self): + self._sessions = {} + self._locks: dict[str, asyncio.Lock] = {} + + async def get_token(self, key: str) -> str: + if key not in self._locks: + self._locks[key] = asyncio.Lock() + async with self._locks[key]: + if self._is_expired(key): + self._sessions[key] = await self._refresh_token(key) + return self._sessions[key]['token'] +``` + +### OPT-2 — Config Hot-Reload 🟢 + +```python +from watchfiles import awatch + +async def watch_config(config_path: str, on_reload: callable): + async for _ in awatch(config_path): + on_reload(Config.from_file(config_path)) +```