Tasks: Native Claude Agent SDK Integration — Phase 4: SK Backend Refactor¶
Feature: 021-claude-agent-sdk
Input: Design documents from /docs/product-specs/021-claude-agent-sdk/
Scope: Phase 4 only (SK Backend Refactor). Continues from tasks-foundations.md (T023b).
Approach: TDD — test tasks are written and must fail before implementation tasks begin
References: Each task includes a document reference (doc:L<line>) pointing to the relevant design decision
FRs: FR-010, FR-011, FR-012b (plan.md:L258-259)
Format: [ID] [P?] Description (doc:Lline)¶
- [P]: Task is parallelizable (different files, no unresolved dependencies)
- No
[US?]labels: Phase 4 is a foundational refactor prerequisite for all user stories - TDD rule: Test tasks MUST precede their implementation tasks
Phase 4 Overview¶
Purpose: Extract SK-specific code from agent_factory.py into the lib/backends/ package. The existing code is not deleted — it is moved and wrapped to implement the AgentBackend Protocol. After Phase 4, no downstream consumer (test runner executor, chat executor, chat session, models) imports ChatHistory or other SK types. Two modules retain internal SK usage until Phase 9: agent_factory.py (legacy execution path) and llm_context_generator.py (LLM prompt construction).
Files created:
- src/holodeck/lib/backends/sk_backend.py (new — core SK backend implementation)
- src/holodeck/lib/backends/selector.py (new — backend routing by provider)
Files modified (source):
- src/holodeck/lib/test_runner/agent_factory.py (thin facade preserving public API)
- src/holodeck/lib/chat_history_utils.py (remove SK-specific function)
- src/holodeck/models/chat.py (remove ChatHistory type dependency)
- src/holodeck/chat/executor.py (remove ChatHistory import, use result.response)
- src/holodeck/chat/session.py (remove ChatHistory import, use list[dict])
- src/holodeck/lib/test_runner/executor.py (remove extract_last_assistant_content, use result.response)
Files modified (tests):
- tests/unit/lib/test_chat_history_utils.py (remove tests for moved function)
- tests/unit/models/test_chat_models.py (update ChatSession constructions to list[dict])
- tests/unit/agent/test_executor.py (add response to mocked AgentExecutionResult)
- tests/unit/lib/test_runner/test_executor.py (remove stale extract_last_assistant_content import)
Key invariant: Existing unit tests MUST pass after documented test updates (T032b, T033b, T035b, T037b). The refactor is behaviour-preserving — production code paths produce identical results through new interfaces.
Key goal: After Phase 4, no downstream consumer (test runner executor, chat executor, chat session, models) imports ChatHistory from semantic_kernel (plan.md:L277). agent_factory.py and llm_context_generator.py retain internal SK usage until Phase 9.
Phase 4A: Tests (write these first — they MUST fail before implementation)¶
NOTE: Write ALL test tasks before implementing. Run
pytest tests/unit/lib/backends/test_sk_backend.py tests/unit/lib/backends/test_selector.py -n autoto confirm all fail withImportErrororModuleNotFoundErrorbefore starting Phase 4B.
Tests for sk_backend.py¶
-
T024 [P] Write unit tests for
_extract_response()private function intests/unit/lib/backends/test_sk_backend.py: (1) extracts last assistant message content from aChatHistorywith multiple messages; (2) returns empty string for emptyChatHistory; (3) returns empty string forChatHistorywith no assistant messages; (4) returns empty string forChatHistorywhere last assistant message hasNonecontent. MockChatHistoryusingsemantic_kernel.contents.ChatHistorydirectly in the test (this is the one place outsidesk_backend.pywhere ChatHistory is used — in tests that verify the SK-specific implementation). (plan.md:L276, research.md:L295-306, quickstart.md:L362-366) -
T025 [P] Write unit tests for
SKBackendimplementingAgentBackendProtocol intests/unit/lib/backends/test_sk_backend.py: (1)SKBackendpassesisinstance(backend, AgentBackend)runtime check; (2)initialize()calls_ensure_tools_initialized()on the underlying factory; (3)invoke_once(message)returnsExecutionResultwith non-emptyresponsefield (mock the underlying SK agent invocation); (4)invoke_once()correctly mapstool_callsfromAgentExecutionResulttoExecutionResult.tool_calls; (5)invoke_once()correctly mapstool_resultsfromAgentExecutionResulttoExecutionResult.tool_results; (6)invoke_once()correctly mapsTokenUsagefromAgentExecutionResulttoExecutionResult.token_usage; (7)invoke_once()populatesresponsevia_extract_response()— never returns empty string when ChatHistory has an assistant message; (8)teardown()callsshutdown()on the underlying factory. Use mocks/patches for the SK Kernel, Agent, and ChatHistory — do NOT make real LLM calls. (plan.md:L269-277, data-model.md:L297-338, contracts/execution-result.md:L100-110) -
T026 [P] Write unit tests for
SKSessionimplementingAgentSessionProtocol intests/unit/lib/backends/test_sk_backend.py: (1)SKSessionpassesisinstance(session, AgentSession)runtime check; (2)send(message)returnsExecutionResultwith populatedresponse; (3)send(message)preserves conversation state across multiple calls (multi-turn); (4)send_streaming(message)is an async generator that yields the complete response as a single chunk (no-op streaming — plan.md:L274); (5)close()succeeds without error. Mock the underlyingAgentThreadRun— do NOT make real LLM calls. (plan.md:L271-275, data-model.md:L260-293, contracts/execution-result.md:L86-96)
Tests for selector.py¶
- T027 [P] Write unit tests for
BackendSelector.create()routing logic intests/unit/lib/backends/test_selector.py: (1)provider=openaireturnsSKBackendinstance; (2)provider=azure_openaireturnsSKBackendinstance; (3)provider=ollamareturnsSKBackendinstance; (4)provider=anthropicraisesBackendInitErrorwith message indicating Claude backend is not yet implemented (placeholder until Phase 8); (5) returned backend hasinitialize()called automatically bycreate(). Mock the SKBackend constructor to avoid real kernel creation. Createtests/unit/lib/backends/test_selector.py(new file). (plan.md:L448-464, research.md:L295-306)
Tests for models/chat.py update¶
- T028 [P] Write unit tests for updated
ChatSession.historytype intests/unit/models/test_chat_models.py(existing file — add new test classTestChatSessionHistoryType): (1)ChatSessionconstruction with defaulthistory=[]succeeds; (2)ChatSessionconstruction with pre-populatedhistory=[{"role": "user", "content": "hi"}]succeeds; (3) otherChatSessionfields (session_id,agent_config,started_at,message_count,state,metadata) are unchanged; (4)ChatSessionno longer requiresarbitrary_types_allowedinmodel_config(sincelist[dict]is a native Pydantic type). Note: these tests will fail initially becauseChatSession.historyis still typed asChatHistory. (plan.md:L285-288, data-model.md:L179-198)
Tests for caller updates¶
- T029 [P] Write unit tests for
AgentExecutionResult.responsefield intests/unit/lib/test_runner/test_agent_factory.py(existing file — add new test class or functions): (1)AgentExecutionResultaccepts aresponse: strfield with default""; (2) whenAgentThreadRun.invoke()completes,result.responseis populated with the last assistant message content (not empty); (3) backward compat:result.chat_historyis still accessible (not removed in Phase 4 — removed in Phase 9). This tests the transition strategy: callers can useresult.responseimmediately and Phase 9 removeschat_history. (plan.md:L276-277, contracts/execution-result.md:L150-156)
Checkpoint: All test tasks written. Run pytest tests/unit/lib/backends/test_sk_backend.py tests/unit/lib/backends/test_selector.py -n auto — all MUST fail.
Phase 4B: Implementation¶
Core: sk_backend.py¶
- T030 Create
src/holodeck/lib/backends/sk_backend.pyimplementing: (plan.md:L269-277, data-model.md:L297-338, contracts/execution-result.md:L86-128, quickstart.md:L362-378)
Private function: _extract_response(history: ChatHistory) -> str
- Moved from chat_history_utils.py:extract_last_assistant_content()
- Searches reversed history.messages for last assistant message
- Returns str(content) or empty string if not found
- All ChatHistory usage is confined to this module
Class: SKSession(AgentSession)
- __init__(self, thread_run: AgentThreadRun) — wraps existing AgentThreadRun
- send(self, message: str) -> ExecutionResult — calls thread_run.invoke(message), converts AgentExecutionResult to ExecutionResult using _extract_response() for the response field
- send_streaming(self, message: str) -> AsyncGenerator[str, None] — calls send() and yields the complete response as a single chunk (no-op streaming per plan.md:L274)
- close(self) -> None — no-op (SK thread runs have no explicit cleanup)
- Keep all SK imports (ChatHistory, ChatHistoryAgentThread, etc.) internal to this module
Class: SKBackend(AgentBackend)
- __init__(self, agent_config: Agent, execution_config: ExecutionConfig | None, force_ingest: bool, mode: Literal["test", "chat"], max_retries: int = DEFAULT_MAX_RETRIES, retry_delay_seconds: float = DEFAULT_RETRY_DELAY_SECONDS) — stores config only; creates AgentFactory instance synchronously (same as current AgentFactory.__init__()), passing max_retries and retry_delay_seconds through
- initialize(self) -> None — calls self._factory._ensure_tools_initialized()
- invoke_once(self, message: str, context: list[dict] | None) -> ExecutionResult — creates isolated thread run, invokes, converts result. Response is populated via _extract_response(result.chat_history). Token usage is mapped from result.token_usage. Tool calls and tool results are passed through.
- create_session(self) -> SKSession — creates thread run via factory, wraps in SKSession
- teardown(self) -> None — calls self._factory.shutdown()
Core: selector.py¶
- T031 Create
src/holodeck/lib/backends/selector.pyimplementing: (plan.md:L448-464, research.md:L295-306)
Class: BackendSelector
- @staticmethod async def create(agent: Agent, tool_instances: list | None = None, mode: Literal["test", "chat"] = "test", execution_config: ExecutionConfig | None = None, force_ingest: bool = False) -> AgentBackend
- If agent.model.provider == ProviderEnum.ANTHROPIC → raise BackendInitError("Claude-native backend not yet implemented. Use a non-Anthropic provider or wait for Phase 8.")
- Otherwise → create SKBackend(agent, execution_config, force_ingest, mode), call await backend.initialize(), return backend
- Import SKBackend from holodeck.lib.backends.sk_backend
- Import BackendInitError from holodeck.lib.backends.base
Refactor: chat_history_utils.py¶
- T032 Update
src/holodeck/lib/chat_history_utils.py: (plan.md:L279-283) - Remove
extract_last_assistant_content()function entirely — it has been moved tosk_backend.pyas_extract_response() - Remove
from semantic_kernel.contents import ChatHistoryimport — no SK types remain in this module - Keep
extract_tool_names(tool_calls: list[dict[str, Any]]) -> list[str]— this function is SK-free and used by the test runner -
Update module docstring to reflect the remaining function only
-
T032b Update
tests/unit/lib/test_chat_history_utils.pyto reflect the removed function: (plan.md:L279-283) - Remove the entire
TestExtractLastAssistantContentclass (7 tests) — the function it tests has been moved tosk_backend.pyand is now covered by T024's tests for_extract_response() - Remove
from holodeck.lib.chat_history_utils import extract_last_assistant_contentimport - Remove
from semantic_kernel.contents import ChatHistoryimport (if present) - Keep
TestExtractToolNamesclass —extract_tool_names()remains inchat_history_utils.py - Update module docstring to reflect the remaining test class only
Update: models/chat.py¶
- T033 Update
src/holodeck/models/chat.py: (plan.md:L285-288, data-model.md:L179-198) - Change
ChatSession.history: ChatHistory→history: list[dict[str, Any]] = Field(default_factory=list) - Remove
from semantic_kernel.contents import ChatHistoryimport - Remove
arbitrary_types_allowed=TruefromChatSession.model_config(no longer needed —list[dict]is a native Pydantic type; change tomodel_config = ConfigDict()or remove the line if no other config is needed) - Add docstring to
historyfield:"""Conversation history as provider-agnostic dicts. Format: {"role": "user"|"assistant", "content": str}. SK backend serializes ChatHistory internally. Claude backend leaves empty (state in subprocess).""" -
Verify all other
ChatSessionfields remain unchanged -
T033b Update
tests/unit/models/test_chat_models.pyto match the updatedChatSession.historytype: (plan.md:L285-288) - Remove
from semantic_kernel.contents import ChatHistoryimport - Replace all
ChatSession(history=ChatHistory(), ...)constructions withChatSession(history=[], ...)(2 occurrences:test_session_started_at_in_futureat L60-63 andtest_session_defaultsat L68) - Verify all existing
ChatSessionassertions still pass with thelist[dict]type
Update: agent_factory.py (Thin Facade)¶
- T034 Update
src/holodeck/lib/test_runner/agent_factory.py: (plan.md:L290-294, contracts/execution-result.md:L150-156) - Add
response: str = ""field toAgentExecutionResultdataclass aftertoken_usage(last field, with default — all existing positional constructions continue to work). This provides a transition path so callers can useresult.responseinstead ofextract_last_assistant_content(result.chat_history) - Update
AgentThreadRun._invoke_agent_impl()to populateresponsefield: after extracting tool calls and before returning, call_extract_response_from_history(self.chat_history)(a new private method onAgentThreadRunthat calls the same logic as_extract_responsefromsk_backend.py) and setresponse=extracted_contentin the returnedAgentExecutionResult - Add private method
AgentThreadRun._extract_response_from_history(self, history: ChatHistory) -> strthat extracts the last assistant message — identical logic tosk_backend._extract_response(). This avoids importing from sk_backend (which would create a bidirectional dependency). The duplication is intentional and temporary — Phase 9 removesAgentExecutionResultentirely in favour ofExecutionResult. - Remove the import of
extract_last_assistant_contentfromchat_history_utilsif present (it's not directly imported in agent_factory.py, but verify) - Preserve all existing public API:
AgentFactory,AgentThreadRun,AgentExecutionResultremain importable - Preserve
AgentFactory.__init__()synchronous kernel creation (no behaviour change) - Note:
AgentFactorydoes NOT delegate toBackendSelectorin Phase 4. The delegation is deferred to Phase 9 whenTestExecutorswitches to usingBackendSelector.create()directly. In Phase 4,AgentFactoryremains the canonical entry point with its existing implementation, andSKBackendis a parallel implementation of the same logic behind theAgentBackendProtocol.
Update: chat/executor.py (Remove ChatHistory)¶
- T035 Update
src/holodeck/chat/executor.pyto remove allChatHistoryimports and usage: (plan.md:L277) - Remove
from semantic_kernel.contents import ChatHistory(L10) - Remove
from holodeck.lib.chat_history_utils import extract_last_assistant_content(L12) - Update
execute_turn()(L124): replacecontent = extract_last_assistant_content(result.chat_history)withcontent = result.response— uses the newAgentExecutionResult.responsefield populated by T034 - Update
get_history() -> ChatHistory(L154): change return type tolist[dict[str, Any]]. Internally, ifself._thread_runexists, serialize itschat_historytolist[dict]via a helper:[{"role": m.role.value, "content": str(m.content)} for m in self._thread_run.chat_history.messages](use.value— SK'sAuthorRoleis a string enum;str()produces"AuthorRole.ASSISTANT"not"assistant"). If no thread run, return[]. This preserves theget_history()public API while removing the SK type from the return signature. - Update
clear_history()(L164): no changes needed (already just sets_thread_run = None) -
Verify no other ChatHistory references remain in the file
-
T035b Update
tests/unit/agent/test_executor.pyto match the updatedAgentExecutionResultandchat/executor.pychanges: (plan.md:L277) - Remove
from semantic_kernel.contents import ChatHistoryimport (L9) - Update all mocked
AgentExecutionResultconstructions (4+ occurrences) to includeresponse="<expected content>"— e.g.,AgentExecutionResult(tool_calls=[], tool_results=[], chat_history=mock_history, response="Hi there!"). Without this,result.responsewill be""after T035 changes executor to readresult.responseinstead of extracting fromchat_history - Update
test_get_history_returns_chat_history(L196): rename totest_get_history_returns_listand update assertions to expectlist[dict]return type instead ofChatHistory - Verify all existing test assertions still pass
Update: chat/session.py (Remove ChatHistory)¶
- T036 Update
src/holodeck/chat/session.pyto remove allChatHistoryimports and usage: (plan.md:L277, data-model.md:L179-198) - Remove
from semantic_kernel.contents import ChatHistory(L8) - Update
start()(L72): replacehistory = ChatHistory()withhistory: list[dict[str, Any]] = []— matches the updatedChatSession.historytype from T033 - Update
ChatSessionconstruction (L73-77): passhistory=history(which is now[]) - Verify no other ChatHistory references remain in the file
Update: test_runner/executor.py (Remove extract_last_assistant_content)¶
- T037 Update
src/holodeck/lib/test_runner/executor.pyto useresult.responseinstead ofextract_last_assistant_content: (plan.md:L277) - Update import (L32-35): remove
extract_last_assistant_contentfromfrom holodeck.lib.chat_history_utils import (...). Keepextract_tool_names. - Update
_execute_single_test()(L551): replaceagent_response = extract_last_assistant_content(result.chat_history)withagent_response = result.response— uses the newAgentExecutionResult.responsefield from T034 - Verify no other
extract_last_assistant_contentorChatHistoryreferences remain in the file -
Note:
extract_tool_names(result.tool_calls)(L552) remains unchanged —extract_tool_namesis SK-free and stays inchat_history_utils.py -
T037b Update
tests/unit/lib/test_runner/test_executor.pyto remove staleextract_last_assistant_contentreferences: (plan.md:L277) - Remove the local import of
extract_last_assistant_contentfromchat_history_utilsat L2297 (insidetest_extract_response_text_empty_history) - Remove or rewrite the
test_extract_response_text_empty_historytest — the function it tests no longer exists inchat_history_utils.py(moved tosk_backend.pyas_extract_response(), covered by T024) - Update any mocked
AgentExecutionResultconstructions (L261-264) to includeresponse="<expected content>"— without this,result.responsewill be""after T037 changes executor to readresult.response - Verify no other
extract_last_assistant_contentorChatHistoryreferences remain in the test file
Update: backends/__init__.py exports¶
- T038 Update
src/holodeck/lib/backends/__init__.pyto export key types for convenient imports: (plan.md:L185) - Export from
base:ExecutionResult,AgentSession,AgentBackend,BackendError,BackendInitError,BackendSessionError,BackendTimeoutError - Export from
selector:BackendSelector - Export from
sk_backend:SKBackend,SKSession - Use
__all__list for explicit public API
Checkpoint: All implementation and test-update tasks complete. Run pytest tests/unit/ -n auto — ALL tests MUST pass (existing tests pass after documented updates in T032b, T033b, T035b, T037b). The refactor is behaviour-preserving.
Phase 4C: Verification & Quality¶
- T039 Run full existing unit test suite:
make test-unit— zero regressions. If any test fails, the failure is in the refactor (not the test) and MUST be fixed before proceeding. Pay special attention to: (plan.md:L299) tests/unit/lib/test_runner/— agent factory teststests/unit/models/— chat model tests-
tests/unit/chat/— chat executor/session tests (if they exist) -
T040 Run code quality checks — all MUST pass: (plan.md:L553-560)
make format # Black + Ruff formatting make lint-fix # Auto-fix linting issues make type-check # MyPy strict mode — all new code must pass make security # Bandit + Safety + detect-secrets -
T041 Integration Gate (plan.md:L303): Before starting Phase 5, run a full
holodeck testworkflow against a real non-Anthropic agent (e.g., an OpenAI agent with at least one vectorstore tool). All test cases must execute successfully. This prevents asyncio lifecycle regressions from the SK refactor from being buried under Claude backend code in later phases. Document the test command and result in a comment on the PR or in the commit message.
Dependencies & Execution Order¶
Within Phase 4: TDD Order¶
1. Write all test tasks (T024–T029) — confirm they FAIL
2. Implement core modules (T030–T031) — sk_backend.py, selector.py
3. Refactor existing modules (T032–T037) — chat_history_utils, models/chat, agent_factory, callers
4. Update existing tests (T032b, T033b, T035b, T037b) — align mocks and imports with refactored code
5. Update exports (T038)
6. Verify (T039–T041) — all tests pass, quality checks, integration gate
Task Dependencies (strict order)¶
T024, T025, T026 ─────┐
T027 ─────────────────┤ (all test tasks can be written in parallel)
T028 ─────────────────┤
T029 ─────────────────┘
│
▼ (confirm all tests fail)
T030 ◄─── sk_backend.py (depends on: T024–T026 tests exist and fail)
│
T031 ◄─── selector.py (depends on: T027 test exists; T030 for SKBackend import)
│
├── T032 chat_history_utils.py (depends on: T030 — function moved there)
│ └── T032b test_chat_history_utils.py (depends on: T032 — function removed)
├── T033 models/chat.py (depends on: T028 test exists)
│ └── T033b test_chat_models.py (depends on: T033 — history type changed)
│
T034 ◄─── agent_factory.py (depends on: T029 test exists; T030 for understanding)
│
├── T035 chat/executor.py (depends on: T032, T033, T034 — uses result.response)
│ └── T035b test_executor.py [agent] (depends on: T035 — mocks need response field)
├── T036 chat/session.py (depends on: T033 — uses list[dict])
└── T037 test_runner/executor.py (depends on: T032, T034 — uses result.response)
└── T037b test_executor.py [test_runner] (depends on: T037 — stale import removed)
│
T038 ◄─── __init__.py exports (depends on: T030, T031)
│
T039 ◄─── test suite (depends on: all implementation + test-update tasks)
T040 ◄─── quality checks (depends on: T039 passing)
T041 ◄─── integration gate (depends on: T039, T040)
Parallel Opportunities¶
Test writing (T024–T029): All six test tasks target different files or distinct test classes — they CAN all be written in parallel:
- Developer A: test_sk_backend.py (T024, T025, T026 — same file, sequential within)
- Developer B: test_selector.py (T027)
- Developer C: test_chat_models.py additions (T028) + test_agent_factory.py additions (T029)
Implementation (T030–T038): - T030 and T031 are sequential (T031 imports from T030) - T032, T033 can run in parallel with T034 (different files) - T035, T036, T037 can run in parallel (different files, all depend on T032–T034) - T038 can run in parallel with T035–T037
Test updates (T032b, T033b, T035b, T037b): - T032b depends on T032 (same module's tests) - T033b depends on T033 (same model's tests) - T035b depends on T035 (same executor's tests) - T037b depends on T037 (same executor's tests) - T032b∥T033b∥T035b∥T037b can run in parallel (different test files)
Parallel Example¶
# Phase 4A — Write all tests in parallel:
# Dev A: tests/unit/lib/backends/test_sk_backend.py (T024, T025, T026)
# Dev B: tests/unit/lib/backends/test_selector.py (T027)
# Dev C: tests/unit/models/test_chat_models.py additions (T028) + test_agent_factory.py (T029)
# Confirm new backend tests fail:
pytest tests/unit/lib/backends/test_sk_backend.py tests/unit/lib/backends/test_selector.py -n auto
# Phase 4B — Sequential core, then parallel callers, then parallel test updates:
# T030 → T031 (sequential — selector imports sk_backend)
# T032 + T033 + T034 (parallel — different files)
# T035 + T036 + T037 (parallel — different files, all depend on T032–T034)
# T032b + T033b + T035b + T037b (parallel — different test files, align with refactored code)
# T038 (after T030, T031)
# Phase 4C — Sequential verification:
# T039 → T040 → T041
Task Summary¶
| Group | Tasks | Tests | Implementations | Parallel |
|---|---|---|---|---|
| Phase 4A (Tests) | T024–T029 | 6 | 0 | T024–T029 (across files) |
| Phase 4B (Implementation) | T030–T038 | 0 | 9 | T032∥T033∥T034, T035∥T036∥T037 |
| Phase 4B (Test Updates) | T032b, T033b, T035b, T037b | 4 | 0 | T032b∥T033b∥T035b∥T037b |
| Phase 4C (Verification) | T039–T041 | 0 | 0 | Sequential |
| Total | 22 tasks | 10 | 9 |
Continuity from Phase 3¶
Phase 3 ended with T023b. Phase 4 starts at T024.
Prerequisites from earlier phases:
- src/holodeck/lib/backends/__init__.py exists (T007, Phase 1)
- tests/unit/lib/backends/__init__.py exists (T007b, Phase 1)
- src/holodeck/lib/backends/base.py exists with ExecutionResult, AgentSession, AgentBackend, exception hierarchy (T008, Phase 1)
- src/holodeck/models/claude_config.py exists (T014, Phase 2)
- src/holodeck/lib/backends/validators.py exists (T023, Phase 3)
- AgentFactoryError consolidated to lib/errors.py only (T023b, Phase 3)
Design Notes¶
Why AgentExecutionResult.response (transition strategy)¶
The plan states that SKBackend.invoke_once() MUST populate ExecutionResult.response via _extract_response() (plan.md:L276). However, in Phase 4, callers (test_runner/executor.py, chat/executor.py) still use AgentFactory → AgentThreadRun → AgentExecutionResult. To achieve the "no ChatHistory imports outside sk_backend.py" goal without prematurely switching callers to the new backend interface (that's Phase 9/10), we add a response field to AgentExecutionResult and populate it in AgentThreadRun._invoke_agent_impl().
This duplication of response extraction logic is intentional and temporary:
- sk_backend.py:_extract_response() — used by SKBackend.invoke_once() and SKSession.send()
- agent_factory.py:AgentThreadRun._extract_response_from_history() — used by the legacy path
Phase 9 removes AgentExecutionResult entirely and switches all callers to ExecutionResult via BackendSelector. The duplication is eliminated at that point.
Why AgentFactory does NOT delegate to BackendSelector yet¶
The plan (plan.md:L290-294) states AgentFactory "internally delegates to BackendSelector.create()." However, BackendSelector is fully wired with ClaudeBackend routing in Phase 8 (plan.md:L448-464). In Phase 4, BackendSelector only supports SKBackend and raises BackendInitError for Anthropic providers.
Wiring AgentFactory through BackendSelector in Phase 4 would be premature — the TestExecutor and ChatSessionManager still expect AgentFactory's synchronous init pattern and AgentThreadRun's invoke() API. The delegation happens in Phase 9 when callers are switched to BackendSelector.create() directly.
In Phase 4, AgentFactory retains its existing implementation. SKBackend exists as a parallel implementation of the same logic behind the AgentBackend Protocol — ready for Phase 9 to wire in.
Why ChatHistory is still present in agent_factory.py and llm_context_generator.py¶
After Phase 4, two modules outside sk_backend.py still import ChatHistory from semantic_kernel:
agent_factory.py:AgentFactoryandAgentThreadRunuseChatHistoryinternally for the legacy execution path. Phase 9 removes this entirely (it becomes a thin re-export facade).llm_context_generator.py(L200-203): UsesChatHistoryfor LLM prompt construction — unrelated to agent execution. Deferred to Phase 9 for cleanup.
These are accepted because the important goal — that no downstream consumer (test runner executor, chat executor, chat session, models) imports ChatHistory — IS achieved in Phase 4.
chat/executor.py:get_history() return type change¶
Phase 10 (plan.md:L510-513) replaces get_history() with two methods: get_message_count() and get_history() -> list[dict]. Phase 4 changes the return type to list[dict[str, Any]] as a prerequisite (removing the ChatHistory return type). The internal serialization (ChatHistory.messages → list[dict]) bridges the gap until Phase 10 implements the full replacement.
Code Quality Gates¶
Run after Phase 4 implementation:
make format # Black + Ruff formatting
make lint-fix # Auto-fix linting issues
make type-check # MyPy strict mode (all new code must pass)
make test-unit # pytest tests/unit/ -n auto
make security # Bandit + Safety + detect-secrets
What Follows Phase 4¶
Phase 4 is the Integration Gate before Phases 5–7 can proceed in parallel:
Phase 4 (SK backend refactor — this file) ← MUST be stable + tested first
│
├── Phase 5 (tool adapters) ─┐
├── Phase 6 (MCP bridge) ├── parallel after Phase 4 confirmed green
└── Phase 7 (OTel bridge) ─┘
Phases 5, 6, and 7 can progress in parallel ONLY after T041 (Integration Gate) passes.