diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c80420..d4d9950 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Contributors add user-facing entries under `[Unreleased]` in the same PR. Mainta ### Changed +- **Skills (finance/uk_companies_house_handler):** Completed Phase v2a upgrade (#220). Added `context` parameter to carry forward session state (`company_number`, `company_name`, `officer_filter`, etc.). Added `partial` envelope status and optional `pipeline` state to support paused multi-step pipelines (noted as v2b prep in instructions). Updated `_get_officers` to fallback to `company_name` from context and added tests for fallback logic. Updated `examples/gemini_uk_companies_house_handler.py` into a fully interactive chat loop. - **Documentation:** CONTRIBUTING and contributor workflow checklists โ€” update `card.json` and output fixtures together when changing `execute()` output (#199). ## [0.4.5] - 2026-07-16 diff --git a/docs/skills/uk_companies_house_handler.md b/docs/skills/uk_companies_house_handler.md index b117cbb..124f755 100644 --- a/docs/skills/uk_companies_house_handler.md +++ b/docs/skills/uk_companies_house_handler.md @@ -6,7 +6,7 @@ **Recommended install:** `pip install "skillware[finance_uk_companies_house_handler]"`. See [Install extras](../usage/install_extras.md). [Skill Library](README.md) ยท [Testing](../TESTING.md) -A deterministic UK Companies House API handler for agents. Provides structured operations for company search, profile lookup, officer and PSC listing, filing history, and intent-to-operation mapping with UK corporate terminology translation. Returns status-based responses (`ready`, `needs_input`, `error`) with disambiguation support. +A deterministic UK Companies House API handler for agents. Provides structured operations for company search, profile lookup, officer and PSC listing, filing history, and intent-to-operation mapping with UK corporate terminology translation. Returns status-based responses (`ready`, `partial`, `needs_input`, `error`) with disambiguation support. ## Capabilities @@ -16,6 +16,7 @@ A deterministic UK Companies House API handler for agents. Provides structured o - **Persons with Significant Control (PSC)**: List beneficial owners with natures of control, equivalent to the US concept of "beneficial owner" or "shareholder". - **Filing History**: List filings (accounts, confirmation statements, incorporations) with optional category filtering and document metadata links. - **Intent Mapping**: Translate common user intent keywords (CEO, owner, shareholder) to the correct UK Companies House actions and build suggested action pipelines. +- **State Tracking (Context)**: Automatically carries forward session state (like `company_number`, `company_name`, and active filters) between sequential tool calls to seamlessly link multi-step operations. ## Internal Architecture @@ -31,7 +32,8 @@ The system prompt teaches the AI to: ### 2. The Body (`skill.py`) A single `execute()` entry point dispatches to six action handlers: - **HTTP layer**: Authenticated requests using API key as HTTP Basic username. -- **Status envelope**: Every response includes `status` (ready/needs_input/error), `fetched_at` (UTC ISO), and `source`. +- **Status envelope**: Every response includes `status` (ready/partial/needs_input/error), `fetched_at` (UTC ISO), and `source`. +- **State propagation**: Extracts and updates a strict 5-key `context` schema in every response, falling back to these values if omitted in subsequent requests. - **Error handling**: Catches HTTP errors (404, 429, 500), timeouts, and connection failures. ### 3. The Knowledge (`data/`) @@ -203,6 +205,13 @@ Prompt mode via `SkillLoader.to_ollama_prompt(bundle)`; match `"tool": "finance/ "company_status": "dissolved" } ], + "context": { + "company_number": null, + "company_name": null, + "last_action": "resolve_company", + "officer_filter": null, + "selected_transaction_id": null + }, "agent_hint": "Ask the user which company they mean before calling get_officers.", "next_actions": ["get_company_profile", "get_officers"], "fetched_at": "2026-07-05T00:00:00+00:00" @@ -215,7 +224,14 @@ Prompt mode via `SkillLoader.to_ollama_prompt(bundle)`; match `"tool": "finance/ { "action": "get_officers", "company_number": "00102498", - "active_only": true + "active_only": true, + "context": { + "company_number": "00102498", + "company_name": "BP P.L.C.", + "last_action": "resolve_company", + "officer_filter": null, + "selected_transaction_id": null + } } ``` @@ -225,6 +241,13 @@ Prompt mode via `SkillLoader.to_ollama_prompt(bundle)`; match `"tool": "finance/ { "status": "ready", "company_number": "00102498", + "context": { + "company_number": "00102498", + "company_name": "BP P.L.C.", + "last_action": "get_officers", + "officer_filter": null, + "selected_transaction_id": null + }, "officers": [ { "name": "SMITH, John", diff --git a/examples/gemini_uk_companies_house_handler.py b/examples/gemini_uk_companies_house_handler.py index eb9bc4e..4d0a5f3 100644 --- a/examples/gemini_uk_companies_house_handler.py +++ b/examples/gemini_uk_companies_house_handler.py @@ -1,10 +1,10 @@ """ -Non-interactive Gemini agent loop for finance/uk_companies_house_handler. +Interactive Gemini agent loop for finance/uk_companies_house_handler. -Demonstrates the full "Who is the CEO of BP?" flow: +Demonstrates an interactive flow to query UK companies: 1. map_intent -- translate intent keywords to an action pipeline 2. resolve_company -- search by name, receive ranked candidates - 3. Agent disambiguates (scripted: picks BP P.L.C.) + 3. Agent disambiguates (asks user to pick if needed) 4. get_officers -- list directors for the resolved company number Environment (live mode): @@ -44,59 +44,70 @@ def main() -> None: system_instruction = bundle["instructions"] - user_query = "Who is the CEO of BP?" - print(f"User: {user_query}\n") - - response = client.models.generate_content( + print("\n" + "=" * 60) + print("UK Companies House Gemini Agent") + print("=" * 60) + print("This agent can look up UK companies, officers, PSCs, and filings.") + print("Try asking:") + print(" - 'Who is the CEO of BP?'") + print(" - 'Show me the filing history for Tesco'") + print(" - 'Who owns Monzo?'") + print("\nType 'exit' or 'quit' to stop.") + print("=" * 60) + + chat = client.chats.create( model="gemini-2.5-flash", - contents=user_query, config=types.GenerateContentConfig( tools=[tool], system_instruction=system_instruction, ), ) - while response.candidates and response.candidates[0].content.parts: - part = response.candidates[0].content.parts[0] - if not part.function_call: + while True: + try: + user_query = input("\nUser: ").strip() + except EOFError: break - fn_name = part.function_call.name - fn_args = dict(part.function_call.args) - print("--- Tool Call ---") - print(f"Function: {fn_name}") - print(f"Arguments: {json.dumps(fn_args, indent=2)}") - - expected_tool_name = SkillLoader._sanitize_gemini_tool_name( - bundle["manifest"]["name"] - ) - if fn_name != expected_tool_name: - print(f"Unknown tool: {fn_name}") + if not user_query: + continue + + if user_query.lower() in ("exit", "quit"): break - api_result = handle_tool_call(skill, fn_args) - print("\n--- Skill Result ---") - print(json.dumps(api_result, indent=2)) - - response = client.models.generate_content( - model="gemini-2.5-flash", - contents=[ - "Use this tool result to answer the original request.", - { - "function_response": { - "name": fn_name, - "response": {"result": api_result}, - } - }, - ], - config=types.GenerateContentConfig( - tools=[tool], - system_instruction=system_instruction, - ), - ) - - print("\nAgent Final Response:") - print(response.text) + response = chat.send_message(user_query) + + while response.function_calls: + # We assume one tool call at a time for simplicity in this example + tool_call = response.function_calls[0] + fn_name = tool_call.name + fn_args = dict(tool_call.args) + + print("--- Tool Call ---") + print(f"Function: {fn_name}") + print(f"Arguments: {json.dumps(fn_args, indent=2)}") + + expected_tool_name = SkillLoader._sanitize_gemini_tool_name( + bundle["manifest"]["name"] + ) + if fn_name != expected_tool_name: + print(f"Unknown tool: {fn_name}") + api_result = {"error": f"Unknown tool: {fn_name}"} + else: + api_result = handle_tool_call(skill, fn_args) + + print("\n--- Skill Result ---") + print(json.dumps(api_result, indent=2)) + + # Send the tool result back to the chat + response = chat.send_message( + types.Part.from_function_response( + name=fn_name, response={"result": api_result} + ) + ) + + if response.text: + print(f"\nAgent: {response.text}") if __name__ == "__main__": diff --git a/examples/uk_companies_house_handler_common.py b/examples/uk_companies_house_handler_common.py index d4464e3..e79d738 100644 --- a/examples/uk_companies_house_handler_common.py +++ b/examples/uk_companies_house_handler_common.py @@ -141,11 +141,13 @@ def run_scripted_flow(skill: Any) -> None: } ) print(json.dumps(intent_result, indent=2)) + context = intent_result.get("context", {}) # Step 1: Resolve company resolve_result = skill.execute( - {"action": "resolve_company", "query": "BP", "limit": 5} + {"action": "resolve_company", "query": "BP", "limit": 5, "context": context} ) + context = resolve_result.get("context", context) print(json.dumps(resolve_result, indent=2)) # Pick the first candidate @@ -153,38 +155,44 @@ def run_scripted_flow(skill: Any) -> None: company_number = resolve_result["candidates"][0]["company_number"] company_name = resolve_result["candidates"][0]["title"] print(f"\nUser selects: {company_name} ({company_number})\n") + context["company_number"] = company_number + context["company_name"] = company_name elif resolve_result["status"] == "ready": company_number = resolve_result["company_number"] company_name = resolve_result.get("company_name", "") print(f"\nSingle match: {company_name} ({company_number})\n") + context["company_number"] = company_number + context["company_name"] = company_name else: print(f"\nError: {resolve_result.get('message', 'unknown')}") return # Step 2: Get company profile profile_result = skill.execute( - {"action": "get_company_profile", "company_number": company_number} + {"action": "get_company_profile", "context": context} ) + context = profile_result.get("context", context) print(json.dumps(profile_result, indent=2)) # Step 3: Get officers (active only) officers_result = skill.execute( { "action": "get_officers", - "company_number": company_number, "active_only": True, + "context": context, } ) + context = officers_result.get("context", context) print(json.dumps(officers_result, indent=2)) # Step 4: Get PSCs - psc_result = skill.execute({"action": "get_pscs", "company_number": company_number}) + psc_result = skill.execute({"action": "get_pscs", "context": context}) + context = psc_result.get("context", context) print(json.dumps(psc_result, indent=2)) # Step 5: Get filing history - filing_result = skill.execute( - {"action": "get_filing_history", "company_number": company_number} - ) + filing_result = skill.execute({"action": "get_filing_history", "context": context}) + context = filing_result.get("context", context) print(json.dumps(filing_result, indent=2)) print("\n=== flow complete ===") diff --git a/skills/finance/uk_companies_house_handler/instructions.md b/skills/finance/uk_companies_house_handler/instructions.md index 3f6af3b..4c88f33 100644 --- a/skills/finance/uk_companies_house_handler/instructions.md +++ b/skills/finance/uk_companies_house_handler/instructions.md @@ -45,19 +45,21 @@ Always inform the user about these terminology differences when presenting resul 3. Follow the actions exactly as ordered in the `suggested_pipeline`. 4. If a step (like `resolve_company`) returns a status of `needs_input`, present the candidates to the user and wait for their choice before proceeding. 5. Once you have a confirmed `company_number`, continue with the remaining specific actions (`get_officers`, `get_pscs`, etc.) in your pipeline. +6. **State Tracking (Context)**: Every response from the skill includes a `context` object. You MUST pass this exact `context` object back as an argument to subsequent tool calls in the same session. This allows the skill to remember the active `company_number` and `company_name` without you having to manually extract and pass them every time. ## Understanding responses Every response includes a `status` field: - **`ready`**: The data was fetched successfully. Present it to the user. +- **`partial`**: (Note: v2b prep, not currently used) The operation succeeded, but the data returned is incomplete (e.g., due to pagination or a missing sub-resource). Inform the user that there is more information available or synthesize the available data. - **`needs_input`**: Multiple matches or missing information. Present the `candidates` to the user and ask for clarification. Use the `agent_hint` for guidance. - **`error`**: Something went wrong. Check `error_code` and `message`. Common errors: - `not_found`: Company number does not exist. - `rate_limited`: Too many requests; wait and retry. - `missing_company_number`: You need to resolve a company first. -Every response includes `fetched_at` (UTC timestamp) and `source` โ€” always cite these when presenting data. +Every response includes `fetched_at` (UTC timestamp), `source`, and a `context` block โ€” always cite the timestamp and source when presenting data. Responses may also include an optional `pipeline` object (Note: v2b prep, not currently used; e.g. `{"completed_steps": 1, "total_steps": 2}`) representing the partial pipeline state if a multi-step operation is paused. ## Limitations diff --git a/skills/finance/uk_companies_house_handler/manifest.yaml b/skills/finance/uk_companies_house_handler/manifest.yaml index 8c33514..887b5bf 100644 --- a/skills/finance/uk_companies_house_handler/manifest.yaml +++ b/skills/finance/uk_companies_house_handler/manifest.yaml @@ -1,10 +1,10 @@ name: finance/uk_companies_house_handler -version: 1.0.0 +version: 1.1.0 description: | Deterministic UK Companies House API handler for agents. Provides structured operations for company search, profile lookup, officer and PSC listing, filing history, and intent-to-operation mapping with UK corporate terminology - translation. Returns status-based responses (ready, needs_input, error) with + translation. Returns status-based responses (ready, partial, needs_input, error) with disambiguation support. The agent owns natural-language dialogue; the skill returns structured candidates and next actions. short_description: "UK Companies House search, officers, PSC, and filings via structured actions." @@ -49,6 +49,12 @@ parameters: entities: type: object description: "Extracted entities for map_intent (e.g. company_query, role)." + context: + type: object + description: "State carried over from prior skill turns (e.g. company_number, company_name)." + pipeline: + type: object + description: "State of an executing multi-step pipeline (e.g. completed_steps, total_steps) to carry forward." required: - action constitution: | diff --git a/skills/finance/uk_companies_house_handler/skill.py b/skills/finance/uk_companies_house_handler/skill.py index a36ed45..c752ded 100644 --- a/skills/finance/uk_companies_house_handler/skill.py +++ b/skills/finance/uk_companies_house_handler/skill.py @@ -73,11 +73,13 @@ def manifest(self) -> Dict[str, Any]: def execute(self, params: Dict[str, Any]) -> Dict[str, Any]: """Dispatch to the appropriate action handler.""" action = params.get("action") + context = params.get("context", {}) if not action: return self._error_response( "missing_action", "The 'action' parameter is required.", + context=context, ) if action not in _VALID_ACTIONS: @@ -85,8 +87,20 @@ def execute(self, params: Dict[str, Any]) -> Dict[str, Any]: "invalid_action", f"Unknown action '{action}'. " f"Valid actions: {sorted(_VALID_ACTIONS)}", + context=context, ) + # Fallback parameters from context if not explicitly provided + if "company_number" not in params and "company_number" in context: + params["company_number"] = context["company_number"] + if "officer_filter" not in params and "officer_filter" in context: + params["officer_filter"] = context["officer_filter"] + if ( + "selected_transaction_id" not in params + and "selected_transaction_id" in context + ): + params["selected_transaction_id"] = context["selected_transaction_id"] + # Validate company_number is present when required if action in _ACTIONS_REQUIRING_COMPANY_NUMBER: company_number = params.get("company_number") @@ -97,6 +111,7 @@ def execute(self, params: Dict[str, Any]) -> Dict[str, Any]: "parameter. Use 'resolve_company' first to find " "the correct company number.", next_actions=["resolve_company"], + context=context, ) dispatch = { @@ -109,7 +124,29 @@ def execute(self, params: Dict[str, Any]) -> Dict[str, Any]: } try: - return dispatch[action](params) + result = dispatch[action](params) + + # Carry forward and merge context + new_context = { + "company_number": context.get("company_number"), + "company_name": context.get("company_name"), + "last_action": action, + "officer_filter": context.get("officer_filter"), + "selected_transaction_id": context.get("selected_transaction_id"), + } + if result.get("company_number"): + new_context["company_number"] = result["company_number"] + if result.get("company_name"): + new_context["company_name"] = result["company_name"] + if "officer_filter" in params: + new_context["officer_filter"] = params["officer_filter"] + if "selected_transaction_id" in params: + new_context["selected_transaction_id"] = params[ + "selected_transaction_id" + ] + + result["context"] = new_context + return result except requests.exceptions.HTTPError as exc: status_code = exc.response.status_code if exc.response is not None else None if status_code == 404: @@ -117,30 +154,36 @@ def execute(self, params: Dict[str, Any]) -> Dict[str, Any]: "not_found", "The requested resource was not found at " "Companies House. Check the company number.", + context=context, ) if status_code == 429: return self._error_response( "rate_limited", "Companies House API rate limit exceeded. " "Wait and retry.", + context=context, ) return self._error_response( "api_error", f"Companies House API returned HTTP {status_code}.", + context=context, ) except requests.exceptions.Timeout: return self._error_response( "timeout", "Companies House API request timed out.", + context=context, ) except requests.exceptions.ConnectionError: return self._error_response( "connection_error", "Could not connect to the Companies House API.", + context=context, ) except Exception as exc: return self._error_response( "internal_error", f"Unexpected error: {type(exc).__name__}: {exc}", + context=context, ) # --- Action Handlers --- @@ -296,10 +339,18 @@ def _get_officers(self, params: Dict[str, Any]) -> Dict[str, Any]: officers.append(officer) company_name = data.get("company_name", "") - # Try to get company name from a links-based lookup if - # the officers endpoint did not return it directly + # Fallback to context first if not company_name: - company_name = data.get("links", {}).get("company", "") + company_name = params.get("context", {}).get("company_name", "") + + # Fetch company profile to get the name if the officers endpoint omitted it + if not company_name: + try: + profile = self._get_company_profile({"company_number": company_number}) + if profile.get("status") == "ready": + company_name = profile.get("company_name", "") + except Exception: + pass result = { "company_number": company_number, @@ -547,6 +598,8 @@ def _ready_response( data: Dict[str, Any], next_actions: Optional[List[str]] = None, source: str = "companies_house_api", + context: Optional[Dict[str, Any]] = None, + pipeline: Optional[Dict[str, int]] = None, ) -> Dict[str, Any]: """Build a successful response envelope.""" response = { @@ -554,6 +607,33 @@ def _ready_response( "source": source, "fetched_at": self._fetched_at(), } + if context is not None: + response["context"] = context + if pipeline is not None: + response["pipeline"] = pipeline + response.update(data) + if next_actions: + response["next_actions"] = next_actions + return response + + def _partial_response( + self, + data: Dict[str, Any], + next_actions: Optional[List[str]] = None, + source: str = "companies_house_api", + context: Optional[Dict[str, Any]] = None, + pipeline: Optional[Dict[str, int]] = None, + ) -> Dict[str, Any]: + """Build a partial status response envelope.""" + response = { + "status": "partial", + "source": source, + "fetched_at": self._fetched_at(), + } + if context is not None: + response["context"] = context + if pipeline is not None: + response["pipeline"] = pipeline response.update(data) if next_actions: response["next_actions"] = next_actions @@ -565,6 +645,8 @@ def _needs_input_response( candidates: List[Dict[str, Any]], agent_hint: str = "", next_actions: Optional[List[str]] = None, + context: Optional[Dict[str, Any]] = None, + pipeline: Optional[Dict[str, int]] = None, ) -> Dict[str, Any]: """Build a disambiguation response envelope.""" response = { @@ -573,6 +655,10 @@ def _needs_input_response( "candidates": candidates, "fetched_at": self._fetched_at(), } + if context is not None: + response["context"] = context + if pipeline is not None: + response["pipeline"] = pipeline if agent_hint: response["agent_hint"] = agent_hint if next_actions: @@ -585,6 +671,8 @@ def _error_response( message: str, agent_hint: str = "", next_actions: Optional[List[str]] = None, + context: Optional[Dict[str, Any]] = None, + pipeline: Optional[Dict[str, int]] = None, ) -> Dict[str, Any]: """Build a structured error response envelope.""" response = { @@ -593,6 +681,10 @@ def _error_response( "message": message, "fetched_at": self._fetched_at(), } + if context is not None: + response["context"] = context + if pipeline is not None: + response["pipeline"] = pipeline if agent_hint: response["agent_hint"] = agent_hint if next_actions: diff --git a/skills/finance/uk_companies_house_handler/test_skill.py b/skills/finance/uk_companies_house_handler/test_skill.py index 329d949..0b522bc 100644 --- a/skills/finance/uk_companies_house_handler/test_skill.py +++ b/skills/finance/uk_companies_house_handler/test_skill.py @@ -33,6 +33,7 @@ def test_manifest_consistency(skill, manifest): skill_manifest = skill.manifest assert skill_manifest["name"] == manifest["name"] assert skill_manifest["version"] == manifest["version"] + assert "context" in skill_manifest["parameters"]["properties"] def test_missing_api_key(): @@ -308,6 +309,77 @@ def test_get_officers_active_only(mock_request, skill): assert result["officers"][0]["name"] == "SMITH, John" +@patch("skills.finance.uk_companies_house_handler.skill.requests.request") +def test_get_officers_context_only_company_number(mock_request, skill): + """get_officers works with company_number provided only via context.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "items": [ + { + "name": "SMITH, John", + "officer_role": "director", + "appointed_on": "2020-03-01", + "nationality": "British", + } + ], + "total_results": 1, + "active_count": 1, + } + mock_response.raise_for_status = MagicMock() + mock_request.return_value = mock_response + + result = skill.execute( + { + "action": "get_officers", + "context": {"company_number": "00102498"}, + } + ) + + assert result["status"] == "ready" + assert result["company_number"] == "00102498" + assert len(result["officers"]) == 1 + assert result["officers"][0]["name"] == "SMITH, John" + + +@patch("skills.finance.uk_companies_house_handler.skill.requests.request") +def test_get_officers_company_name_fallback_via_profile(mock_request, skill): + """Officers action falls back to profile fetch if company_name is missing.""" + mock_officers_response = MagicMock() + mock_officers_response.json.return_value = { + "items": [ + { + "name": "SMITH, John", + "officer_role": "director", + "appointed_on": "2020-03-01", + } + ], + "total_results": 1, + "active_count": 1, + } + mock_officers_response.raise_for_status = MagicMock() + + mock_profile_response = MagicMock() + mock_profile_response.json.return_value = { + "company_name": "PROFILE FALLBACK LTD", + "company_status": "active", + "type": "ltd", + } + mock_profile_response.raise_for_status = MagicMock() + + mock_request.side_effect = [mock_officers_response, mock_profile_response] + + result = skill.execute( + { + "action": "get_officers", + "company_number": "00102498", + } + ) + + assert result["status"] == "ready" + assert result["company_name"] == "PROFILE FALLBACK LTD" + assert mock_request.call_count == 2 + + # --- get_pscs Tests --- @@ -526,3 +598,53 @@ def test_connection_error(mock_request, skill): assert result["status"] == "error" assert result["error_code"] == "connection_error" + + +# --- v2a Enhancements Tests --- + + +@patch("skills.finance.uk_companies_house_handler.skill.requests.request") +def test_context_propagation(mock_request, skill): + """Context should carry forward and supply missing parameters.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "company_name": "TEST COMPANY LTD", + "company_status": "active", + "type": "ltd", + } + mock_response.raise_for_status = MagicMock() + mock_request.return_value = mock_response + + result = skill.execute( + { + "action": "get_company_profile", + "context": { + "company_number": "12345678", + "officer_filter": "Smith", + }, + } + ) + + assert result["status"] == "ready" + assert "context" in result + ctx = result["context"] + assert ctx["last_action"] == "get_company_profile" + assert ctx["company_number"] == "12345678" + assert ctx["company_name"] == "TEST COMPANY LTD" + assert ctx["officer_filter"] == "Smith" + + +def test_partial_response(skill): + """_partial_response should build the correct envelope.""" + result = skill._partial_response( + data={"some_key": "some_val"}, + next_actions=["do_something_else"], + context={"state": 1}, + pipeline={"completed_steps": 1, "total_steps": 2}, + ) + assert result["status"] == "partial" + assert result["some_key"] == "some_val" + assert result["next_actions"] == ["do_something_else"] + assert result["context"] == {"state": 1} + assert result["pipeline"] == {"completed_steps": 1, "total_steps": 2} + assert "fetched_at" in result