Files
ss-tools/specs/035-agent-chat-context/contracts/modules.md
busya ff60865183 feat(agent-chat): 035-agent-chat-context — контекст, guardrails, tools, database discovery
== User stories ==
US1: Контекст с дашборда/датасета → /agent с URL params
US2: Guardrails card — env badge, 7 risk tones, countdown, permission_denied
US3: Tools optimization — retry, timeout, summarise, RBAC + context affinity

== Backend ==
- _context.py (NEW): UIContext validation (7 checks)
- _tool_filter.py (NEW): RBAC + context affinity pipeline
- _confirmation.py: build_confirmation_contract_v2, permission_denied_payload
- tools.py: superset_list_databases, retry/summarise/timeout wrappers
- app.py: _inject_uicontext, _inject_env_id_into_tools, database
  prefetch в runtime context
- _persistence.py: prefetch_databases()
- agent_superset_explore.py: GET /databases endpoint
- _llm_async_http.py, _persistence.py: fix double /v1 в LLM URL
  (LM Studio Unexpected endpoint)

== Frontend ==
- AgentChatModel.svelte.ts: 5 atoms, 3 actions, countdown, context
- AgentChat.svelte: production banner, process steps, debug panel
- ConfirmationCard.svelte: 7 risk tones, permission_denied, countdown
- ToolCallCard.svelte: retrying/timeout/cancelled states
- StreamProcessor.svelte.ts: tool_retry, timeout, permission_denied
- TopNavbar: sparkles icon + Ассистент
- sidebarNavigation: AI section
- DashboardHeader, datasets/+page: contextual AI buttons
- Icon: sparkles, brain, cpu icons
- tailwind: assistant category colors
- i18n: en/ru nav keys

== Tests ==
- 159 backend agent tests (+16 US3: retry, timeout, summarise, contracts)
- 2544 frontend tests (+11 model + component tests)
- 15 JSON fixtures (10 API + 5 model)

== Specs ==
- specs/035-agent-chat-context/: spec, UX, plan, tasks, research,
  data-model, contracts, quickstart, traceability, fixtures, checklists

Closes #035
2026-07-04 22:47:17 +03:00

342 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#region ModulesContract [C:4] [TYPE ADR] [SEMANTICS contracts,modules,agent-chat]
@defgroup Contracts Module and function contracts for 035-agent-chat-context implementation.
@RATIONALE Content model updated for Path B (/agent dedicated page), Gradio payload safety, two-layer RBAC enforcement, and permission_denied flow separation.
@REJECTED Path A (drawer panel on any page) — rejected in favor of dedicated /agent route for simpler state management.
#region AgentChat.LangGraph.Handler [C:4] [TYPE Function] [SEMANTICS agent-chat,handler,context]
# @ingroup AgentChat
# @BRIEF Extended agent_handler — accepts uicontext JSON appended at end of positional args, injects into runtime context, filters tools by objectType.
# @PRE JWT valid, user authenticated. uicontext_str is valid JSON or None.
# @POST UIContext injected into system prompt. Tools filtered by objectType. Stream unchanged for uicontext=None (backward-compat).
# @SIDE_EFFECT Calls LLM, invokes tools, writes checkpoints. UIContext validated and logged via CoT logger.
# @RELATION DEPENDS_ON -> [AgentChat.ToolFilter]
# @RELATION DEPENDS_ON -> [AgentChat.Confirmation]
# @DATA_CONTRACT Input: (message, history, convId, userId, userJwt, envId, uicontext_str?) -> Output: AsyncGenerator[str]
# @TEST_EDGE: null_uicontext -> All 22 tools passed to LLM (backward-compat).
# @TEST_EDGE: dashboard_context -> Only 9 dashboard tools passed.
# @TEST_EDGE: invalid_json_uicontext -> Gracefully ignored with audit log, all tools passed.
# @TEST_EDGE: malformed_objectType -> Rejected with 422, audit log written.
# @RATIONALE uicontext_str appended at END of positional args (position 6) — inserting in the middle would break existing Gradio clients that pass 6 args. Null default ensures zero breakage.
# @REJECTED Middle-position insertion — rejected: breaking change for existing Gradio clients.
#endregion AgentChat.LangGraph.Handler
#region AgentChat.ToolFilter [C:3] [TYPE Module] [SEMANTICS agent-chat,tools,filter,context]
# @defgroup AgentChat Context-aware tool filtering by objectType.
# @LAYER Service
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
# #region AgentChat.ToolFilter.Pipeline [C:4] [TYPE Function] [SEMANTICS agent-chat,tools,filter,pipeline]
# @ingroup AgentChat
# @BRIEF Canonical tool selection pipeline — applies in order: RBAC filter → context affinity filter.
# @POST Returns list[BaseTool] after applying: (1) role-based exclusion, (2) context-type hard filter. show_capabilities always included.
# @SIDE_EFFECT CoT logs reason for each excluded tool.
# @RELATION DEPENDS_ON -> [AgentChat.Tools]
# @DATA_CONTRACT Input: (all_tools, user_role, object_type) -> Output: filtered_tools
# @RATIONALE Ordered pipeline ensures RBAC is enforced FIRST (security), then context filtering (UX). Two-layer: prompt filtering + invocation enforcement (see AGTL-FR-005).
# @REJECTED Embedding-based filtering — rejected for initial release; postponed to post-MVP (AGTL-FR-006).
# @TEST_EDGE: null_objectType -> Returns all RBAC-allowed tools.
# @TEST_EDGE: dashboard+analyst -> Returns 9 dashboard tools minus any requiring admin.
# @TEST_EDGE: unknown_objectType -> Returns all RBAC-allowed tools (graceful fallback).
_CONTEXT_TOOL_AFFINITY: dict[str, set[str]] = {
"dashboard": {"search_dashboards", "get_health_summary", "deploy_dashboard",
"run_llm_validation", "run_llm_documentation", "execute_migration",
"create_branch", "commit_changes"},
"dataset": {"superset_explore_database", "superset_format_sql", "superset_audit_permissions",
"superset_execute_sql", "superset_create_dataset", "search_dashboards",
"get_task_status", "list_environments"},
"migration": {"execute_migration", "search_dashboards", "get_health_summary",
"deploy_dashboard", "list_environments"},
}
_TOOL_PERMISSIONS: dict[str, list[str]] = {
"deploy_dashboard": ["admin"], "commit_changes": ["admin"],
"create_branch": ["admin"], "run_backup": ["admin"],
"execute_migration": ["admin"], "start_maintenance": ["admin"],
"end_maintenance": ["admin"],
}
def build_tool_pipeline(tools, user_role, object_type):
"""Apply RBAC + context filtering in canonical order."""
pass
# #endregion AgentChat.ToolFilter.Pipeline
# #region AgentChat.ToolFilter.InvocationGuard [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,security,guard]
# @ingroup AgentChat
# @BRIEF Invocation-time RBAC enforcement — validates tool execution permission regardless of prompt filtering.
# @PRE tool_name extracted from tool invocation request. User role resolved from JWT.
# @POST If role insufficient: yield permission_denied SSE event (NOT confirm_required). If role sufficient: proceed.
# @SIDE_EFFECT CoT logger.explore on denied. Audit log written.
# @DATA_CONTRACT Input: (tool_name, user_role) -> Output: None (allowed) or yield permission_denied SSE
# @RATIONALE Two-layer enforcement (AGTL-FR-005): prompt filtering prevents LLM from proposing disallowed tools, but invocation guard catches hallucinated/replayed/direct calls. Security-critical — prompt filtering alone is insufficient.
# @TEST_EDGE: analyst_calls_deploy -> permission_denied SSE, tool not executed.
# @TEST_EDGE: admin_calls_deploy -> allowed, no event.
# @TEST_EDGE: unknown_tool -> rejected with error, not permission_denied.
def enforce_tool_permission(tool_name, user_role):
"""Invocation-time RBAC enforcement — yield permission_denied or proceed."""
pass
# #endregion AgentChat.ToolFilter.InvocationGuard
#endregion AgentChat.ToolFilter
#region AgentChat.Tools.Retry [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,retry,http]
# @ingroup AgentChat
# @BRIEF Fixed-delay retry wrapper for read-only tool HTTP calls on transient errors.
# @PRE Tool is classified as read-only (risk_level="safe"). Error is 5xx or connection error.
# @POST Retries once with fixed 1s delay. On success: returns response. On exhaust: raises with retry_exhausted=True.
# @SIDE_EFFECT HTTP call retried; CoT loggers REASON/EXPLORE/REFLECT on each attempt.
# @RELATION DEPENDS_ON -> [EXT:httpx:AsyncClient]
# @TEST_EDGE: first_attempt_502 -> Auto-retries once, succeeds.
# @TEST_EDGE: both_attempts_502 -> Raises error with retry_exhausted=True.
# @TEST_EDGE: write_tool_502 -> No retry (read-only only), raises immediately.
# @RATIONALE 1 retry with 1s fixed delay catches ~80% of transient failures. Read-only only — write idempotency not guaranteed.
# @REJECTED Exponential backoff — rejected: single retry cannot be exponential; 2+ retries with backoff adds 3s+ latency.
# @REJECTED Retry all tools — rejected: write operations may not be idempotent.
async def _retry_read_tool(func, *args, **kwargs):
"""Auto-retry read-only tool on transient HTTP errors. Fixed 1s delay."""
pass
# #endregion AgentChat.Tools.Retry
#region AgentChat.Tools.Summarise [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,summarise,response]
# @ingroup AgentChat
# @BRIEF Structured response summarisation — top-N items + total count for large tool responses.
# @POST When response >4000 chars and JSON array: "Found {total} items:\n - {item1}\n ...\n + {total-5} more."
# When >4000 chars and JSON object: "Result keys: {keys}. Values: {sample}..."
# When >4000 chars and not JSON: truncate at sentence boundary near 4000 chars + "..."
# When ≤4000 chars: return original.
# @RATIONALE Structured truncation preserves semantic value for LLM context better than hard cut.
# @REJECTED LLM-based summarisation — extra token cost and latency.
# @REJECTED Pagination in every tool — 22 tools to modify. Terminology: "structured truncation" not "semantic summarisation."
def _summarise_response(text: str, limit: int = 4000) -> str:
"""Structured truncation — top-N + count for large responses."""
pass
# #endregion AgentChat.Tools.Summarise
#region AgentChat.Tools.Timeout [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,timeout]
# @ingroup AgentChat
# @BRIEF Configurable timeout wrapper for tool execution (default 30s).
# @PRE asyncio event loop running.
# @POST Returns tool result within timeout. On timeout: yields tool_timeout SSE. For write tools: message says "operation status unknown — check status" (NOT "retry").
# @SIDE_EFFECT asyncio.wait_for with timeout; CoT logger.explore on timeout.
# @RELATION DEPENDS_ON -> [EXT:asyncio]
# @TEST_EDGE: tool_completes_under_timeout -> Returns result normally.
# @TEST_EDGE: read_tool_exceeds_30s -> Yields tool_timeout with retryable=true.
# @TEST_EDGE: write_tool_exceeds_30s -> Yields tool_timeout with retryable=false, status_unknown=true.
# @RATIONALE 30s default balances responsiveness with Superset API variability. Write-tool timeout is NOT safe to retry — the mutation may have succeeded server-side.
# @REJECTED Auto-retry on write timeout — rejected: unsafe, may duplicate mutations.
async def _execute_with_timeout(tool_fn, is_write: bool = False, timeout_s: int = 30):
"""Execute tool with configurable timeout. Write tools: no auto-retry on timeout."""
pass
# #endregion AgentChat.Tools.Timeout
#region AgentChat.Confirmation.GuardV2 [C:4] [TYPE Function] [SEMANTICS agent-chat,hitl,confirmation,guardrails]
# @ingroup AgentChat
# @BRIEF Build extended confirmation contract — three-axis risk classification with env context.
# @POST Returns dict with risk, risk_level, dangerous, env_context, permission_granted, required_role, alternatives.
# @RELATION DEPENDS_ON -> [AgentChat.ToolResolver]
# @RELATION DEPENDS_ON -> [EXT:FastAPI:RBAC]
# @DATA_CONTRACT Input: (tool_name, tool_args, user_role, target_env) -> Output: ConfirmMetadataV2
# @RATIONALE Three-axis classification (tool_risk × env_risk × permission) replaces single-axis prefix heuristic.
# Env resolution: tool_args.env_id > submit envId > UIContext.envId > null.
# Env normalization for risk tiers: env_id containing "prod" → "prod"; "stag"/"test" → "staging"; "dev"/"local" → "dev".
# @REJECTED Single-axis prefix-only — cannot distinguish deploy to prod vs staging.
# @REJECTED LLM-based risk scoring — adds 500ms latency.
# @TEST_EDGE: deploy_to_prod -> risk="write", risk_level="guarded", env_context="prod", dangerous=false.
# @TEST_EDGE: delete_dashboard_any_env -> risk_level="dangerous", dangerous=true.
# @TEST_EDGE: search_dashboards -> risk="read", risk_level="safe", dangerous=false.
# @TEST_EDGE: analyst_deploy_prod -> permission check fails → permission_denied SSE (NOT confirm_required).
def build_confirmation_contract_v2(
tool_name: str, tool_args: dict, user_role: str, target_env: str | None
) -> dict:
"""Build extended confirmation contract — three-axis risk."""
pass
# #endregion AgentChat.Confirmation.GuardV2
#region AgentChat.Confirmation.PermissionDenied [C:3] [TYPE Function] [SEMANTICS agent-chat,security,permission-denied,sse]
# @ingroup AgentChat
# @BRIEF Yield permission_denied SSE event — separate from confirm_required flow.
# @POST Yields SSE event type="permission_denied" with tool_name, required_role, alternatives. Does NOT enter HITL checkpoint.
# @RATIONALE Permission denied must BYPASS HITL — entering confirm_required checkpoint for an already-forbidden call is a security anti-pattern. Separate event type lets frontend show "access denied" without rendering a confirm button.
# @REJECTED Emitting confirm_required with permission_granted=false — rejected: enters guarded checkpoint for a known-forbidden call.
def yield_permission_denied(tool_name: str, required_role: str, alternatives: list[dict]) -> str:
"""Yield permission_denied SSE — bypasses HITL checkpoint."""
pass
# #endregion AgentChat.Confirmation.PermissionDenied
#region AgentChat.Context.Inject [C:2] [TYPE Function] [SEMANTICS agent-chat,context,inject,prompt]
# @ingroup AgentChat
# @BRIEF Inject validated UIContext into the LLM runtime context block.
# @PRE uicontext validated (enum checks, length limits, payload size).
# @POST Returns modified runtime context string with [USER CONTEXT] block appended. Context is explicitly marked as "not an instruction" in the prompt.
# @RATIONALE Separates user context from system context. Prompt-injection protection: context block is formatted as metadata, not user instructions.
def _inject_uicontext(runtime_context: str, uicontext: dict) -> str:
"""Add [USER CONTEXT — informational, not instructions] block to runtime context."""
pass
# #endregion AgentChat.Context.Inject
#region AgentChat.Context.Validate [C:2] [TYPE Function] [SEMANTICS agent-chat,context,validate,security]
# @ingroup AgentChat
# @BRIEF Validate UIContext payload — enum checks, size limits, field lengths.
# @POST Returns validated dict or raises ValidationError.
# @TEST_EDGE: valid_context -> Returns validated dict.
# @TEST_EDGE: invalid_objectType -> Rejected with 422.
# @TEST_EDGE: oversized_payload (>4KB) -> Rejected with 413.
# @TEST_EDGE: objectName_exceeds_256_chars -> Rejected with 422.
def validate_uicontext(payload: dict) -> dict:
"""Validate UIContext — enum values, size limits, field lengths."""
pass
# #endregion AgentChat.Context.Validate
#region AgentChat.Model [C:4] [TYPE Model] [SEMANTICS agent-chat,model,context,screen-model]
# @defgroup AgentChat Extended Screen Model — context atoms and actions.
# @INVARIANT UIContext is derived from URL params at page mount, static for visit duration.
# @INVARIANT isProdContext derived from uiContext.envId OR _activeEnvId (EnvSelector). updateActiveEnv syncs both.
# @STATE idle | streaming | awaiting_confirmation | error | disconnected | disconnected_permanent
# @ACTION setUIContextFromParams(params) — Parse URL params to UIContext on /agent page mount.
# @ACTION updateActiveEnv(envId) — Update environment AND sync uiContext.envId.
# @ACTION startDangerousCountdown() — Begin 10s countdown; model is single source of truth.
# @RELATION DEPENDS_ON -> [AgentChat.Types]
# @RELATION DEPENDS_ON -> [AgentChat.ConnectionManager]
# @RELATION DEPENDS_ON -> [AgentChat.StreamProcessor]
# @RATIONALE Existing AgentChatModel extended rather than replaced — atom addition is additive.
# @REJECTED New context-only model — rejected: cross-model coordination for streaming state.
# @REJECTED initialContext in assistantChatStore — rejected: violates context-per-visit model; store persistence creates stale-context trap.
// NEW atoms (add to AgentChatModel class):
// uiContext: UIContext | null = $state(null);
// dangerousCountdown: number = $state(0);
// dangerousCountdownActive: boolean = $state(false);
// _activeEnvId: string | null = $state(null);
// permissionAlternatives: Array<{action: string; prompt: string}> | null = $state(null);
// NEW actions:
// setUIContextFromParams(params: URLSearchParams): void { ... }
// updateActiveEnv(envId: string | null): void { this._activeEnvId = envId; if (this.uiContext) this.uiContext.envId = envId; }
// startDangerousCountdown(): void { ... }
// #endregion AgentChat.Model
# #region AgentChat.Model.SetContext [C:3] [TYPE Function] [SEMANTICS agent-chat,model,context,action]
# @ingroup AgentChat
# @BRIEF Parse URL search params into UIContext atom on /agent page mount.
# @ACTION Public action — called from +page.svelte onMount.
# @POST this.uiContext populated. this._activeEnvId set. CoT log emitted.
# @TEST_EDGE: full_params -> uiContext populated with all fields, contextVersion=1.
# @TEST_EDGE: no_params -> uiContext is null, pill shows "Контекст не выбран".
# @TEST_EDGE: malformed_objectType -> treated as null (frontend validation).
// setUIContextFromParams(params: URLSearchParams): void {
// const objectType = (["dashboard","dataset","migration"].includes(params.get("objectType")||"")
// ? params.get("objectType") : null) as UIContext["objectType"];
// ...
// }
# #endregion AgentChat.Model.SetContext
#region AgentChat.Component [C:4] [TYPE Component] [SEMANTICS agent,chat,ui,svelte]
# @ingroup AgentChat
# @BRIEF Agent chat panel — extended with prod warning background+banner and context-aware process steps.
# @RELATION BINDS_TO -> [AgentChat.Model]
# @RELATION DEPENDS_ON -> [$lib/ui/Icon]
# @UX_STATE prod_env -> bg-warning-light background + red PRODUCTION banner atop chat panel.
# @UX_STATE non_prod_env -> Standard bg-surface-page background, no banner.
# @UX_FEEDBACK Prod banner appears/disappears reactively on env change.
# @UX_RECOVERY EnvSelector → select non-prod → background restores.
# @RATIONALE Reactive background change gives immediate, non-intrusive prod warning.
# @REJECTED Modal warning on every prod message — too intrusive.
// CHANGES to AgentChat.svelte:
// 1. Root div: class binding for prod background
// 2. Prod banner using $lib/ui/Icon (existing)
// 3. Process steps: first step uses model.contextPillLabel
#endregion AgentChat.Component
#region AgentChat.ConfirmationCard [C:4] [TYPE Component] [SEMANTICS agent-chat,confirmation,hitl,guardrails,svelte]
# @ingroup AgentChat
# @BRIEF HITL guardrails card — env badge, risk toning, dangerous countdown, permission-denied state.
# @RELATION BINDS_TO -> [AgentChat.Model]
# @RELATION DEPENDS_ON -> [$lib/ui/Button]
# @RELATION DEPENDS_ON -> [$lib/ui/Icon]
# @UX_STATE read/write_staging/write_prod/write_dev/dangerous/loading_confirm/loading_deny — 7 states.
# @UX_STATE permission_denied — Separate rendering path; received as SSE event type="permission_denied" (NOT confirm_required).
# @UX_FEEDBACK Dangerous countdown aria-live="assertive" every 1s (aligned with 1s decrement).
# @UX_RECOVERY Deny → "Операция отменена" agent message in chat.
# @UX_RECOVERY Permission denied → close card → agent message with alternatives.
# @INVARIANT Card renders from confirm_required for permitted operations. permission_denied events bypass HITL checkpoint.
# @INVARIANT Countdown timer owned by model (dangerousCountdown atom), component reads model only.
# @RATIONALE Model-owns-countdown eliminates race condition between model and component timers.
# @REJECTED Component-owned countdown — rejected: violates model-first pattern, creates timer duplication.
// CHANGES to ConfirmationCard.svelte:
// 1. 7 risk-based states (add write_dev)
// 2. Countdown reads model.dangerousCountdown (no component timer)
// 3. permission_denied: listens for SSE event type="permission_denied"
// 4. Risk-based toneClasses extended with write_dev
#endregion AgentChat.ConfirmationCard
#region AgentChat.ToolCallCard [C:3] [TYPE Component] [SEMANTICS agent-chat,tools,retry,timeout,svelte]
# @ingroup AgentChat
# @BRIEF Inline tool call display — retrying, timeout, cancelled states.
# @RELATION DEPENDS_ON -> [$lib/ui/Button]
# @UX_STATE retrying/timeout/cancelled
# @UX_FEEDBACK Retry button triggers message resubmission for read-only tools. Write tool timeout: no retry button ("check operation status").
# @UX_RECOVERY Read timeout → retry. Write timeout → check status.
# @RATIONALE Write-tool retry is unsafe — timeout does not mean mutation didn't happen.
# @REJECTED Retry for all timeouts — rejected: write retry may duplicate mutations.
// CHANGES to ToolCallCard.svelte:
// 1. onRetry prop shown only for read tools / transient errors
// 2. Write timeout: "⏱️ Operation status unknown — check Superset" (no retry button)
#endregion AgentChat.ToolCallCard
#region AgentChat.Page [C:3] [TYPE Component] [SEMANTICS agent-chat,page,svelte]
# @ingroup AgentChat
# @BRIEF Agent page wrapper — reads UIContext from URL params on mount.
# @RELATION BINDS_TO -> [AgentChat.Model]
# @UX_STATE mount -> Reads URL params, calls model.setUIContextFromParams().
// CHANGES to +page.svelte:
// import { page } from "$app/stores";
// onMount(() => {
// const params = new URLSearchParams($page.url.search);
// model.setUIContextFromParams(params);
// });
#endregion AgentChat.Page
#region AgentChat.StreamProcessor [C:4] [TYPE Module] [SEMANTICS agent-chat,streaming,sse,svelte]
# @ingroup AgentChat
# @BRIEF SSE stream processor — handles tool_retry, tool_timeout, permission_denied (new), extended confirm_required.
# @RELATION BINDS_TO -> [AgentChat.Model]
# @RELATION DEPENDS_ON -> [AgentChat.Types]
// NEW event handler:
// case "permission_denied": render permission-denied card (separate from confirm_required)
// EXTENDED event handler:
// case "confirm_required": parse dangerous, env_context, permission_granted. If permission_granted=false → redirect to permission_denied handler.
// NEW handlers:
// case "tool_retry": update ToolCall status=retrying
// case "tool_timeout": update ToolCall status=timeout; if write tool → no retry button
#endregion AgentChat.StreamProcessor
#region AgentChat.Types [C:2] [TYPE Module] [SEMANTICS agent-chat,types,svelte]
# @ingroup AgentChat
# @BRIEF Shared TypeScript types — UIContext, ToolAffinityEntry, ConfirmMetadataV2, PermissionDeniedMetadata.
// ADD interfaces (see data-model.md for full shapes):
// export interface UIContext { objectType, objectId, objectName, envId, route, contextVersion }
// export interface ConfirmMetadataV2 { ... with dangerous, env_context, permission_granted }
// export interface PermissionDeniedMetadata { type:"permission_denied", tool_name, required_role, alternatives }
// export type ToolCallStatus = "executing" | "retrying" | "completed" | "failed" | "timeout" | "cancelled";
// EXTEND ToolCall: +retryCount?, +maxRetries?, +timeoutSeconds?, +isWriteTool?
#endregion AgentChat.Types
#endregion ModulesContract