Plan Analysis: 021-claude-agent-sdk¶
Date: 2026-02-21 Status: Findings recorded — decisions pending
Cross-reference of plan.md against the actual codebase. Findings are ordered by severity.
🔴 Blockers — Must fix before implementation¶
1. AgentExecutionResult has no response field¶
Problem: AgentThreadRun.invoke() returns AgentExecutionResult(tool_calls, tool_results, chat_history, token_usage). There is no response: str. The response text is extracted by consumers after the call:
# executor.py:551
agent_response = extract_last_assistant_content(result.chat_history)
# chat/executor.py:124
content = extract_last_assistant_content(result.chat_history)
The plan's ExecutionResult correctly has response: str, but never says who populates it when wrapping the SK backend. Without explicitly moving the extraction step inside SKBackend.invoke_once(), every SK-backend invocation returns ExecutionResult(response="", ...) — a silent failure.
Fix: Phase 4 must explicitly state that SKBackend.invoke_once() calls extract_last_assistant_content(result.chat_history) internally before building ExecutionResult.
Decision required: No — plan correction only.
2. models/chat.py: ChatSession.history: ChatHistory not in plan scope¶
Problem: models/chat.py line 74:
class ChatSession(BaseModel):
history: ChatHistory # ← Semantic Kernel type, in a data model
ChatSessionManager.start() creates it:
history = ChatHistory()
self.session = ChatSession(agent_config=..., history=history, ...)
The plan lists chat/session.py and chat/executor.py as files to update, but never mentions models/chat.py. For Claude agents there is no ChatHistory object. The model cannot represent a Claude chat session as-is.
Options:
- Option A (minimal): Make
historynullable —history: ChatHistory | None = None. Claude sessions don't populate it. Simple, can be revisited. - Option B (clean): Replace with
history: list[dict[str, Any]] = []. SK backend serialisesChatHistoryto dicts internally. Cleaner long-term, more work, touches anything readingsession.history.
Decision required: Choose Option A or B.
3. chat_history_utils.py — SK utility not in plan scope¶
Problem: lib/chat_history_utils.py imports ChatHistory at the module level:
from semantic_kernel.contents import ChatHistory
def extract_last_assistant_content(history: ChatHistory) -> str: ...
def extract_tool_names(tool_calls: list[dict]) -> list[str]: ...
Both executor.py (test runner) and chat/executor.py import from it. The plan never mentions this file. After the refactor, importing this module anywhere in the codebase pulls in SK, contradicting the decoupling goal.
Fix: extract_last_assistant_content moves inside sk_backend.py as a private function. extract_tool_names is SK-free and can stay or be inlined. Add lib/chat_history_utils.py to Phase 4 scope.
Decision required: No — plan correction only.
4. TestExecutor.__init__ is sync — BackendSelector.create() is async¶
Problem: TestExecutor.__init__ synchronously constructs AgentFactory:
self.agent_factory = agent_factory or self._create_agent_factory()
# _create_agent_factory() → AgentFactory(...) → __init__ runs kernel creation synchronously
The plan's BackendSelector.create() is async def with await backend.initialize(). Python __init__ cannot await. The plan's eager-init lifecycle diagram cannot be wired into TestExecutor.__init__.
Fix: The Claude backend must adopt the same lazy-init pattern used by the existing AgentFactory._ensure_tools_initialized(). ClaudeBackend.__init__ stores config only. initialize() is called internally on first invoke_once() or create_session(). The plan's lifecycle diagram needs updating.
Decision required: No — lazy init is clearly correct.
5. VectorStoreTool uses model.provider for embedding dimensions — must use embedding_provider¶
Problem: agent_factory.py:847:
provider_type = self.agent_config.model.provider.value # "anthropic" for Claude agents
await tool.initialize(force_ingest=self._force_ingest, provider_type=provider_type)
provider_type controls embedding vector dimensionality (e.g., OpenAI → 1536 dims). For Claude-native agents, model.provider is "anthropic", but embeddings come from embedding_provider (OpenAI or Azure). Passing provider_type="anthropic" will fail or produce wrong dimensions.
Fix: The Claude backend must pass agent.embedding_provider.provider.value as provider_type. Explicitly call this out in Phase 5.
Decision required: No — plan correction only.
6. Closure bug in tool adapter loop¶
Problem: The quickstart.md and the implied tool_adapters.py implementation use a @tool decorator inside a for loop:
for vs_tool in vectorstore_tools:
@tool(tool_name, ...)
async def search_fn(args: dict) -> dict:
result = await vs_tool.search(args["query"]) # captures 'vs_tool' by reference
Python closures capture variables by reference. After the loop, all search_fn functions reference the last vs_tool. If there are three tools — docs, faq, kb — all three search functions silently call kb.search(). No exception, wrong results.
Fix: Use a factory function:
def make_search_fn(t: VectorStoreTool, name: str) -> SdkMcpTool:
@tool(name, ...)
async def search_fn(args: dict) -> dict:
return {"content": [{"type": "text", "text": await t.search(args["query"])}]}
return search_fn
Must be corrected in both quickstart.md and the actual tool_adapters.py implementation.
Decision required: No — clear bug fix.
🟡 Inconsistencies — Plan vs Actual Code¶
7. TokenUsage vs dict — field names and types are incompatible¶
Problem: The codebase has a TokenUsage Pydantic model with fields prompt_tokens, completion_tokens, total_tokens (with cross-field validation). The plan proposes ExecutionResult.token_usage: dict[str, int] with Anthropic SDK field names: input_tokens, output_tokens, total_tokens. These are incompatible in both type and field names. ChatSessionManager accumulates token usage using TokenUsage arithmetic.
Options:
- Option A (recommended): Keep
TokenUsageinExecutionResult. Claude backend translatesinput_tokens → prompt_tokens,output_tokens → completion_tokenson the way out. No changes toChatSessionManager. Three lines of translation inclaude_backend.py. - Option B: Replace everything with
dict[str, int]. UpdateChatSessionManager, all token accumulation code, and the test runner. More work, loses Pydantic validation benefit.
Decision required: Choose Option A or B.
8. OTel bridge silently drops fields users have configured¶
Problem: The existing ObservabilityConfig supports rich exporters that have no Claude Code env var equivalent:
| Config Field | Claude Code Env Var | Status |
|---|---|---|
exporters.otlp.endpoint |
OTEL_EXPORTER_OTLP_ENDPOINT |
✅ Mapped |
exporters.otlp.protocol |
OTEL_EXPORTER_OTLP_PROTOCOL |
✅ Mapped |
metrics.export_interval_ms |
OTEL_METRIC_EXPORT_INTERVAL |
✅ Mapped |
traces.capture_content |
OTEL_LOG_USER_PROMPTS |
✅ Mapped (approximate) |
exporters.azure_monitor |
— | ❌ No mapping |
exporters.prometheus |
— | ❌ No mapping |
traces.redaction_patterns |
— | ❌ No mapping |
traces.sample_rate |
— | ❌ No mapping |
logs.filter_namespaces |
— | ❌ No mapping |
An enterprise user who configured azure_monitor silently loses their observability setup with no feedback when switching to provider: anthropic.
Options:
- Option A (recommended): Emit named warnings at startup for each unsupported field. "The following observability settings are not supported by the Claude-native backend:
azure_monitor,prometheus..." User can adapt. - Option B: Error at startup if unsupported exporters are configured alongside
provider: anthropic. Forces explicit acknowledgement. - Option C: Silently ignore. Acceptable only with explicit changelog entry. Argued against.
Decision required: Choose warning policy (A or B).
9. AgentFactory constructor contract is self-contradictory in plan¶
Problem: The plan says agent_factory.py becomes "a thin backwards-compatible facade." But AgentFactory.__init__ currently creates the SK kernel synchronously. If it delegates to BackendSelector, the constructor either still creates an SK kernel (wasteful for Claude agents) or does nothing (changes observable behaviour, breaking callers who expect a ready kernel).
Fix: Preserve the synchronous constructor contract. For SK agents, __init__ still creates the kernel immediately. For Claude agents, __init__ stores config and does nothing else — async init remains lazy. The plan needs precise language rather than "thin facade."
Decision required: No — plan wording correction.
10. get_history() -> ChatHistory on AgentExecutor not in plan scope¶
Problem: chat/executor.py exposes:
def get_history(self) -> ChatHistory:
if self._thread_run is not None:
return self._thread_run.chat_history
return ChatHistory()
This public method returns an SK type. If called for a Claude-backend session, it returns an empty ChatHistory() (misleading) or crashes. Not listed in the plan's files to update.
Fix: Add to Phase 10 scope. Method either becomes provider-specific with nullable return, or is replaced with a generic get_message_count() -> int that both backends can satisfy.
Decision required: No — plan scope correction.
🔵 Challenged Assumptions¶
11. claude-agent-sdk API not verified from an actual install¶
Problem: The entire backend is planned around class names, method signatures, and dataclass fields researched via web document fetching, not from installing the package. The package is Alpha, released 2026-02-19 — the same day as the spec.
Specific API details that may be wrong:
- Is the class ClaudeAgentOptions or something else?
- Is PermissionMode a string literal or an enum? Is "bypassPermissions" the correct casing?
- Does @tool work as described, or is registration different?
- Does create_sdk_mcp_server() exist by that name?
- Is ResultMessage.structured_output a field?
- Does ClaudeSDKClient exist, or is the stateful API different?
If any of these are wrong, Phase 8 fails on the first import. This is the highest single risk in the plan.
Recommended action: Before any implementation starts, install the package and write a 20-line smoke test against the actual API. This takes an hour and eliminates the largest source of risk.
Decision required: Should SDK verification be a mandatory Phase 0 of implementation?
12. bypassPermissions for test runs may allow destructive operations¶
Problem: The plan auto-overrides to bypassPermissions during holodeck test. If a user has configured bash.enabled: true and file_system.write: true for their agent, test runs will execute shell commands and write files in the working directory without any approval. A test input like "delete the old build artifacts" triggers real deletion in an automated, unattended context.
Options:
- Option A: Disable bash and file system in test mode regardless of agent config. Tests are pure conversation tests. Safest, but may block legitimate scenarios that verify file output.
- Option B: Keep
bypassPermissionsbut require explicit opt-in via--allow-side-effectsCLI flag. Makes risk visible. - Option C: Keep current plan —
bypassPermissionsautomatically. Accept the risk, document it.
Decision required: Choose safety policy for test runs.
13. Multi-turn state for ClaudeSDKClient underspecified¶
Problem: The plan says ClaudeSDKClient handles multi-turn state. But ClaudeAgentOptions has continue_conversation: bool = False. If this defaults to False, each query() call starts a fresh conversation — multi-turn chat is broken from the first test.
Why it matters: holodeck chat is explicitly multi-turn. If the session statefulness configuration is wrong, users ask follow-up questions and the agent has no context of previous messages.
Fix: Verify against the actual SDK (contingent on #11). The correct configuration is likely either continue_conversation=True in ClaudeAgentOptions or maintaining session_id across calls. Phase 8 ClaudeSession implementation must explicitly handle this.
Decision required: Contingent on #11 SDK verification.
14. SK refactor (1,359 lines) is presented as low-risk¶
Problem: Phase 4 moves essentially all of agent_factory.py into sk_backend.py. This code manages Kernel lifecycle, MCP plugin async context managers (with explicit ordering constraints), vectorstore tool initialization, hierarchical document tools, tool filtering, retry logic, and response format wrapping. The comment in AgentFactory.shutdown() explicitly notes: "Must be called from the same task context where the factory was used." This asyncio constraint doesn't move automatically.
Mitigation: Add an explicit integration test gate between Phase 4 and Phase 5. Run a full holodeck test workflow against a real non-Anthropic agent (with at least one MCP tool and one vectorstore tool) before proceeding. This catches lifecycle regressions before they get buried under Claude backend code.
Decision required: No — add integration gate to plan.
15. Phase parallelism is overstated¶
Problem: The plan says Phases 4, 5, 6, 7 can run in parallel after Phase 3. But:
- Phase 4 changes
agent_factory.py, which Phase 9 (test runner) also modifies. Running them in parallel causes merge conflicts on the same file. - If
ExecutionResult's shape changes during Phase 4 (e.g., TokenUsage vs dict — finding #7), Phases 5 and 6 built against it need rework.
Corrected critical path:
1 → 2 → 3 → 4 → [5 + 6 + 7 in parallel] → 8 → 9 → 10 → 11
Phase 4 is not parallelisable with 5 and 6. Phase 4 must be stable and tested before Phases 5 and 6 begin.
Decision required: No — plan correction only.
Decisions Required Before Plan Update¶
| # | Finding | Decision |
|---|---|---|
| 2 | ChatSession.history type |
Option A (nullable) or Option B (list[dict]) |
| 7 | TokenUsage vs dict in ExecutionResult | Option A (keep TokenUsage) or Option B (use dict) |
| 8 | OTel bridge unmapped fields | Warning (A) or Error (B) at startup |
| 11 | SDK API verification | Make Phase 0 mandatory? |
| 12 | Test run safety with bash/file access | Disable (A), opt-in flag (B), or accept risk (C) |
| 13 | Multi-turn ClaudeSDKClient config | Contingent on #11 |
All other findings (#1, 3, 4, 5, 6, 9, 10, 14, 15) are plan corrections that do not require your input.