feat: add skill learning runtime

This commit is contained in:
2026-07-06 18:07:53 +08:00
parent 5658735bca
commit c7ae2d86ec
17 changed files with 1470 additions and 31 deletions
@@ -1,49 +1,49 @@
## 1. Package scaffolding ## 1. Package scaffolding
- [ ] 1.1 Create `skills_learning/` package with `__init__.py`, `models.py`, `synthesis.py`, `versioning.py`, `embeddings.py`, `retrieval.py`, `config.py`, `store.py` - [x] 1.1 Create `skills_learning/` package with `__init__.py`, `models.py`, `synthesis.py`, `versioning.py`, `embeddings.py`, `retrieval.py`, `config.py`, `store.py`
- [ ] 1.2 Add `skills_learning*` to `pyproject.toml`'s `[tool.setuptools.packages.find].include` list and add the embedding-provider SDK dependency - [x] 1.2 Add `skills_learning*` to `pyproject.toml`'s `[tool.setuptools.packages.find].include` list and add the embedding-provider SDK dependency
- [ ] 1.3 Add `skills_learning/config.py` with `SkillAuthoringConfig` (enable flag default `False`, divergence tolerance, embedding model name, default `top_k`) and a module-level accessor mirroring `semantic-scene-runtime`'s/`world-model-runtime`'s config-module pattern - [x] 1.3 Add `skills_learning/config.py` with `SkillAuthoringConfig` (enable flag default `False`, divergence tolerance, embedding model name, default `top_k`) and a module-level accessor mirroring `semantic-scene-runtime`'s/`world-model-runtime`'s config-module pattern
- [ ] 1.4 Add `skills_learning/models.py` defining (or importing, if `skill-catalog-subscription` is already implemented) the shared `Skill`/`FlowTemplateSkill`/`SkillMetadata` dataclass shapes, plus this capability's own `source`, `version`, `parent_version_id` fields - [x] 1.4 Add `skills_learning/models.py` defining (or importing, if `skill-catalog-subscription` is already implemented) the shared `Skill`/`FlowTemplateSkill`/`SkillMetadata` dataclass shapes, plus this capability's own `source`, `version`, `parent_version_id` fields
## 2. Local skill store (skill-authoring, skill-versioning) ## 2. Local skill store (skill-authoring, skill-versioning)
- [ ] 2.1 Implement `skills_learning/store.py`: a local store for locally-synthesized `FlowTemplateSkill` records, separate from `skill-catalog-subscription`'s synced catalog, with `create_version()`, `get_by_id()`, `get_latest_by_name()`, `list_versions(name)` - [x] 2.1 Implement `skills_learning/store.py`: a local store for locally-synthesized `FlowTemplateSkill` records, separate from `skill-catalog-subscription`'s synced catalog, with `create_version()`, `get_by_id()`, `get_latest_by_name()`, `list_versions(name)`
- [ ] 2.2 Enforce `source = "local-synthesis"` tagging on every record written by this store; add a guard/test that this store never writes into or imports a write-path of `skill-catalog-subscription`'s catalog module - [x] 2.2 Enforce `source = "local-synthesis"` tagging on every record written by this store; add a guard/test that this store never writes into or imports a write-path of `skill-catalog-subscription`'s catalog module
- [ ] 2.3 Write unit tests for the store's version-chain semantics: creating a new version does not delete/modify prior versions, and `get_latest_by_name()` returns the highest `version` - [x] 2.3 Write unit tests for the store's version-chain semantics: creating a new version does not delete/modify prior versions, and `get_latest_by_name()` returns the highest `version`
## 3. Timeline extraction and parameter abstraction (skill-authoring) ## 3. Timeline extraction and parameter abstraction (skill-authoring)
- [ ] 3.1 Implement `skills_learning/synthesis.py`'s tool-call extraction: given a `task_id`, read `storage.timeline.Timeline.read(task_id)` and produce an ordered list of `(tool_name, args)` pairs, filtering out read-only tool names (`describe_screen`, `screenshot`, `ui_tree`, `find_text`, `find_icon`) - [x] 3.1 Implement `skills_learning/synthesis.py`'s tool-call extraction: given a `task_id`, read `storage.timeline.Timeline.read(task_id)` and produce an ordered list of `(tool_name, args)` pairs, filtering out read-only tool names (`describe_screen`, `screenshot`, `ui_tree`, `find_text`, `find_icon`)
- [ ] 3.2 Implement skeleton matching: given an extracted tool-name sequence, look up any stored skill (via `store.py`) whose latest version has the identical tool-name sequence - [x] 3.2 Implement skeleton matching: given an extracted tool-name sequence, look up any stored skill (via `store.py`) whose latest version has the identical tool-name sequence
- [ ] 3.3 Implement cross-execution argument diffing: compare extracted argument values position-by-position against a matched stored version's steps, and promote any differing value into a named `{param}` placeholder plus a corresponding entry in the skill's `parameters` schema - [x] 3.3 Implement cross-execution argument diffing: compare extracted argument values position-by-position against a matched stored version's steps, and promote any differing value into a named `{param}` placeholder plus a corresponding entry in the skill's `parameters` schema
- [ ] 3.4 Implement parameter naming: prefer a name derived from the corresponding `SemanticScene.widgets[].purpose` label when available (optional dependency on `semantic/`'s output, degrading gracefully when absent), else fall back to a positional name (e.g. `param_2`) - [x] 3.4 Implement parameter naming: prefer a name derived from the corresponding `SemanticScene.widgets[].purpose` label when available (optional dependency on `semantic/`'s output, degrading gracefully when absent), else fall back to a positional name (e.g. `param_2`)
- [ ] 3.5 Implement `synthesize_flow_skill(goal, timeline) -> FlowTemplateSkill`: orchestrates extraction → skeleton match → diffing → parameter promotion → returns a candidate skill record (not yet persisted) - [x] 3.5 Implement `synthesize_flow_skill(goal, timeline) -> FlowTemplateSkill`: orchestrates extraction → skeleton match → diffing → parameter promotion → returns a candidate skill record (not yet persisted)
- [ ] 3.6 Write unit tests for first-time synthesis (no prior match, zero parameters), second-execution parameter promotion, and identical-repeat synthesis (no spurious new parameters), using canned `TimelineRecord` fixtures - [x] 3.6 Write unit tests for first-time synthesis (no prior match, zero parameters), second-execution parameter promotion, and identical-repeat synthesis (no spurious new parameters), using canned `TimelineRecord` fixtures
## 4. Version divergence detection (skill-versioning) ## 4. Version divergence detection (skill-versioning)
- [ ] 4.1 Implement `skills_learning/versioning.py`'s `diff_flow_versions(stored_steps, executed_steps) -> VersionDiff`: detect tool-name-sequence insertion/deletion/reorder (structural divergence) versus argument-value-only differences - [x] 4.1 Implement `skills_learning/versioning.py`'s `diff_flow_versions(stored_steps, executed_steps) -> VersionDiff`: detect tool-name-sequence insertion/deletion/reorder (structural divergence) versus argument-value-only differences
- [ ] 4.2 Implement version-bump logic: on structural divergence, construct a new `FlowTemplateSkill` version with incremented `version` and `parent_version_id` set to the prior version's id; on argument-only divergence, update the existing version's parameters in place (no bump) - [x] 4.2 Implement version-bump logic: on structural divergence, construct a new `FlowTemplateSkill` version with incremented `version` and `parent_version_id` set to the prior version's id; on argument-only divergence, update the existing version's parameters in place (no bump)
- [ ] 4.3 Write unit tests: extra/missing/reordered step triggers a version bump; identical-sequence-different-values does not bump but does update parameters; assert prior version records remain retrievable and unmodified after a bump - [x] 4.3 Write unit tests: extra/missing/reordered step triggers a version bump; identical-sequence-different-values does not bump but does update parameters; assert prior version records remain retrievable and unmodified after a bump
## 5. Post-task synthesis hook wiring ## 5. Post-task synthesis hook wiring
- [ ] 5.1 Add an optional `on_task_succeeded: Callable[[str, str, Timeline], None] | None = None` constructor argument to `TaskRunner` in `runtime/task.py`, invoked exactly once at the end of `run()` when the final status is `succeeded` - [x] 5.1 Add an optional `on_task_succeeded: Callable[[str, str, Timeline], None] | None = None` constructor argument to `TaskRunner` in `runtime/task.py`, invoked exactly once at the end of `run()` when the final status is `succeeded`
- [ ] 5.2 Wire a default hook (when `on_task_succeeded` is not explicitly passed and Skill Authoring is enabled in `skills_learning/config.py`) that calls `synthesis.synthesize_flow_skill()`, runs versioning via `versioning.py`, and persists the result via `store.py` - [x] 5.2 Wire a default hook (when `on_task_succeeded` is not explicitly passed and Skill Authoring is enabled in `skills_learning/config.py`) that calls `synthesis.synthesize_flow_skill()`, runs versioning via `versioning.py`, and persists the result via `store.py`
- [ ] 5.3 Verify that when Skill Authoring is disabled (default) or `on_task_succeeded` is left `None` and disabled, `TaskRunner.run()`'s behavior and return value are byte-for-byte identical to before this change - [x] 5.3 Verify that when Skill Authoring is disabled (default) or `on_task_succeeded` is left `None` and disabled, `TaskRunner.run()`'s behavior and return value are byte-for-byte identical to before this change
- [ ] 5.4 Write a unit test that runs a fake successful `TaskRunner` loop with Skill Authoring enabled and asserts a skill record is stored after completion, and a test that asserts no store write occurs when disabled - [x] 5.4 Write a unit test that runs a fake successful `TaskRunner` loop with Skill Authoring enabled and asserts a skill record is stored after completion, and a test that asserts no store write occurs when disabled
## 6. Embedding and retrieval (skill-embedding-retrieval) ## 6. Embedding and retrieval (skill-embedding-retrieval)
- [ ] 6.1 Implement `skills_learning/embeddings.py`'s embedding client interface: `embed_skill_text(text) -> list[float] | None`, catching timeout/rate-limit/disabled-config/connection-error internally and returning `None` rather than raising, mirroring `semantic/llm_client.py`'s degrade-safe contract - [x] 6.1 Implement `skills_learning/embeddings.py`'s embedding client interface: `embed_skill_text(text) -> list[float] | None`, catching timeout/rate-limit/disabled-config/connection-error internally and returning `None` rather than raising, mirroring `semantic/llm_client.py`'s degrade-safe contract
- [ ] 6.2 Implement a local `skill_embeddings` index (skill id + version → vector, model name, `updated_at`) in `skills_learning/store.py` or a dedicated `skills_learning/embeddings_store.py` - [x] 6.2 Implement a local `skill_embeddings` index (skill id + version → vector, model name, `updated_at`) in `skills_learning/store.py` or a dedicated `skills_learning/embeddings_store.py`
- [ ] 6.3 Wire embedding computation into the post-synthesis/versioning path: call `embed_skill_text()` on `name + description + goal` for every newly stored skill version, storing the resulting vector (or leaving the skill un-embedded if the call returns `None`) - [x] 6.3 Wire embedding computation into the post-synthesis/versioning path: call `embed_skill_text()` on `name + description + goal` for every newly stored skill version, storing the resulting vector (or leaving the skill un-embedded if the call returns `None`)
- [ ] 6.4 Implement `skills_learning/retrieval.py`'s `retrieve_candidate_skills(goal, top_k) -> list[ScoredSkill]`: embed the incoming goal, compute cosine similarity against every stored skill embedding, and return the top `top_k` ranked results, skipping skills with no stored embedding - [x] 6.4 Implement `skills_learning/retrieval.py`'s `retrieve_candidate_skills(goal, top_k) -> list[ScoredSkill]`: embed the incoming goal, compute cosine similarity against every stored skill embedding, and return the top `top_k` ranked results, skipping skills with no stored embedding
- [ ] 6.5 Write unit tests: ranked ordering for a goal similar to a stored skill's originating goal (using a fake/deterministic embedding function), `top_k` truncation, empty-result case when no skill has an embedding, and a case where an embedding call returns `None` and the skill is stored but excluded from retrieval results - [x] 6.5 Write unit tests: ranked ordering for a goal similar to a stored skill's originating goal (using a fake/deterministic embedding function), `top_k` truncation, empty-result case when no skill has an embedding, and a case where an embedding call returns `None` and the skill is stored but excluded from retrieval results
## 7. Integration tests and validation ## 7. Integration tests and validation
- [ ] 7.1 Write an end-to-end test: run a fake successful task twice with slightly different goal text/argument values through `TaskRunner` (Skill Authoring enabled, embedding client mocked), asserting the second run produces a new skill version with a promoted parameter and its own embedding - [x] 7.1 Write an end-to-end test: run a fake successful task twice with slightly different goal text/argument values through `TaskRunner` (Skill Authoring enabled, embedding client mocked), asserting the second run produces a new skill version with a promoted parameter and its own embedding
- [ ] 7.2 Write an end-to-end test: run a fake successful task, then call `retrieve_candidate_skills()` with a new, semantically similar goal string, asserting the synthesized skill is returned - [x] 7.2 Write an end-to-end test: run a fake successful task, then call `retrieve_candidate_skills()` with a new, semantically similar goal string, asserting the synthesized skill is returned
- [ ] 7.3 Run the full existing `pytest` suite and confirm zero existing test files require content changes (only new `tests/test_skill_*.py`-style files are added) - [x] 7.3 Run the full existing `pytest` suite and confirm zero existing test files require content changes (only new `tests/test_skill_*.py`-style files are added)
- [ ] 7.4 Add a smoke test importing `skills_learning` alongside existing `tests/` smoke coverage, confirming the package has no import-time dependency on `skill-catalog-subscription`'s sync client (only, optionally, its shared model shapes if already implemented) - [x] 7.4 Add a smoke test importing `skills_learning` alongside existing `tests/` smoke coverage, confirming the package has no import-time dependency on `skill-catalog-subscription`'s sync client (only, optionally, its shared model shapes if already implemented)
+2
View File
@@ -8,6 +8,7 @@ dependencies = [
"Appium-Python-Client>=5.1.1", "Appium-Python-Client>=5.1.1",
"fastapi>=0.115.0", "fastapi>=0.115.0",
"mcp>=1.27,<2", "mcp>=1.27,<2",
"openai>=1.0.0",
"paddleocr>=3.0.0", "paddleocr>=3.0.0",
"uvicorn[standard]>=0.30.0", "uvicorn[standard]>=0.30.0",
] ]
@@ -31,6 +32,7 @@ include = [
"perception*", "perception*",
"runtime*", "runtime*",
"semantic*", "semantic*",
"skills_learning*",
"storage*", "storage*",
"tools*", "tools*",
"world*", "world*",
+68 -2
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from inspect import Parameter, signature from inspect import Parameter, signature
@@ -9,6 +10,15 @@ from runtime.context import TaskContext
from runtime.executor import Executor from runtime.executor import Executor
from runtime.planner import PlannedStep, Planner from runtime.planner import PlannedStep, Planner
from semantic.models import SemanticScene from semantic.models import SemanticScene
from skills_learning.config import (
SkillAuthoringConfig,
load_config as load_skill_authoring_config,
)
from skills_learning.embeddings import EmbeddingClient, embed_skill_text
from skills_learning.models import skill_embedding_text
from skills_learning.store import SkillStore, get_default_store
from skills_learning.synthesis import synthesize_flow_skill
from skills_learning.versioning import store_synthesized_skill
from storage.task_metadata import TaskMetadataStore from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline from storage.timeline import Timeline
from tools.describe_screen import describe_screen from tools.describe_screen import describe_screen
@@ -16,6 +26,8 @@ from tools.screenshot import take_screenshot
from world.config import WorldConfig, load_config as load_world_config from world.config import WorldConfig, load_config as load_world_config
from world.model import WorldModel from world.model import WorldModel
logger = logging.getLogger(__name__)
@dataclass @dataclass
class TaskRunnerConfig: class TaskRunnerConfig:
@@ -24,6 +36,7 @@ class TaskRunnerConfig:
Observer = Callable[[str], Scene] Observer = Callable[[str], Scene]
ScreenshotProvider = Callable[[str], bytes] ScreenshotProvider = Callable[[str], bytes]
TaskSucceededHook = Callable[[str, str, Timeline], None]
class TaskRunner: class TaskRunner:
@@ -39,6 +52,10 @@ class TaskRunner:
screenshot_provider: ScreenshotProvider | None = None, screenshot_provider: ScreenshotProvider | None = None,
world_model: WorldModel | None = None, world_model: WorldModel | None = None,
world_config: WorldConfig | None = None, world_config: WorldConfig | None = None,
on_task_succeeded: TaskSucceededHook | None = None,
skill_authoring_config: SkillAuthoringConfig | None = None,
skill_store: SkillStore | None = None,
skill_embedding_client: EmbeddingClient | None = None,
) -> None: ) -> None:
self.planner = planner or Planner() self.planner = planner or Planner()
self.executor = executor or Executor() self.executor = executor or Executor()
@@ -56,6 +73,17 @@ class TaskRunner:
self.world_model = WorldModel(config=self.world_config) self.world_model = WorldModel(config=self.world_config)
else: else:
self.world_model = None self.world_model = None
self.skill_authoring_config = (
skill_authoring_config or load_skill_authoring_config()
)
self.skill_store = skill_store
self.skill_embedding_client = skill_embedding_client
if on_task_succeeded is not None:
self.on_task_succeeded = on_task_succeeded
elif self.skill_authoring_config.enabled:
self.on_task_succeeded = self._default_task_succeeded_hook
else:
self.on_task_succeeded = None
def run(self, task: Task) -> Task: def run(self, task: Task) -> Task:
context = TaskContext(task_id=task.id, goal=task.goal) context = TaskContext(task_id=task.id, goal=task.goal)
@@ -72,8 +100,7 @@ class TaskRunner:
scene=scene, scene=scene,
context=context, context=context,
): ):
self._update_task(task, status="completed", completed=True) return self._complete_task(task)
return task
for step in steps: for step in steps:
executable_step = self._step_for_device(step, task.device_id) executable_step = self._step_for_device(step, task.device_id)
@@ -101,6 +128,45 @@ class TaskRunner:
) )
return task return task
def _complete_task(self, task: Task) -> Task:
self._update_task(task, status="completed", completed=True)
self._notify_task_succeeded(task)
return task
def _notify_task_succeeded(self, task: Task) -> None:
if self.on_task_succeeded is None or self.timeline is None:
return
try:
self.on_task_succeeded(task.id, task.goal, self.timeline)
except Exception as exc:
logger.info("task succeeded hook failed: %s", exc)
def _default_task_succeeded_hook(
self,
task_id: str,
goal: str,
timeline: Timeline,
) -> None:
store = self.skill_store or get_default_store()
candidate = synthesize_flow_skill(
goal,
timeline,
task_id=task_id,
store=store,
)
stored = store_synthesized_skill(store, candidate).skill
vector = embed_skill_text(
skill_embedding_text(stored),
client=self.skill_embedding_client,
config=self.skill_authoring_config,
)
if vector is not None:
store.store_embedding(
stored,
vector,
model_name=self.skill_authoring_config.embedding_model,
)
def _plan( def _plan(
self, self,
goal: str, goal: str,
+16
View File
@@ -0,0 +1,16 @@
"""Local skill learning from completed task timelines."""
from skills_learning.config import SkillAuthoringConfig, load_config
from skills_learning.models import FlowStep, FlowTemplateSkill, Skill, SkillMetadata
from skills_learning.store import SkillStore, get_default_store
__all__ = [
"FlowStep",
"FlowTemplateSkill",
"Skill",
"SkillAuthoringConfig",
"SkillMetadata",
"SkillStore",
"get_default_store",
"load_config",
]
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
import os
from collections.abc import Mapping
from dataclasses import dataclass
DEFAULT_DIVERGENCE_TOLERANCE = 0.0
DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"
DEFAULT_TOP_K = 5
ENABLED_ENV = "SKILL_AUTHORING_ENABLED"
DIVERGENCE_TOLERANCE_ENV = "SKILL_DIVERGENCE_TOLERANCE"
EMBEDDING_MODEL_ENV = "SKILL_EMBEDDING_MODEL"
TOP_K_ENV = "SKILL_RETRIEVAL_TOP_K"
@dataclass(frozen=True)
class SkillAuthoringConfig:
enabled: bool = False
divergence_tolerance: float = DEFAULT_DIVERGENCE_TOLERANCE
embedding_model: str = DEFAULT_EMBEDDING_MODEL
top_k: int = DEFAULT_TOP_K
def load_config(env: Mapping[str, str] | None = None) -> SkillAuthoringConfig:
values = env or os.environ
return SkillAuthoringConfig(
enabled=_parse_bool(values.get(ENABLED_ENV), default=False),
divergence_tolerance=_parse_float(
values.get(DIVERGENCE_TOLERANCE_ENV),
default=DEFAULT_DIVERGENCE_TOLERANCE,
),
embedding_model=values.get(EMBEDDING_MODEL_ENV) or DEFAULT_EMBEDDING_MODEL,
top_k=_parse_int(values.get(TOP_K_ENV), default=DEFAULT_TOP_K),
)
def _parse_bool(value: str | None, *, default: bool) -> bool:
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on", "enabled"}
def _parse_float(value: str | None, *, default: float) -> float:
if value is None:
return default
try:
parsed = float(value)
except ValueError:
return default
return parsed if parsed >= 0 else default
def _parse_int(value: str | None, *, default: int) -> int:
if value is None:
return default
try:
parsed = int(value)
except ValueError:
return default
return parsed if parsed > 0 else default
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from typing import Any, Protocol
from skills_learning.config import SkillAuthoringConfig, load_config
class EmbeddingClient(Protocol):
def embed(self, text: str, *, model: str) -> list[float]:
...
class OpenAIEmbeddingClient:
def __init__(self, *, transport: Any | None = None) -> None:
self._transport = transport
def embed(self, text: str, *, model: str) -> list[float]:
client = self._client()
response = client.embeddings.create(model=model, input=text)
return [float(value) for value in response.data[0].embedding]
def _client(self) -> Any:
if self._transport is not None:
return self._transport
from openai import OpenAI
self._transport = OpenAI()
return self._transport
def embed_skill_text(
text: str,
*,
client: EmbeddingClient | None = None,
config: SkillAuthoringConfig | None = None,
) -> list[float] | None:
settings = config or load_config()
if not settings.enabled:
return None
try:
embedding_client = client or OpenAIEmbeddingClient()
return embedding_client.embed(text, model=settings.embedding_model)
except Exception:
return None
+186
View File
@@ -0,0 +1,186 @@
from __future__ import annotations
from dataclasses import dataclass, field, replace
from datetime import datetime
from typing import Any, Literal
from uuid import uuid4
from core.models import utc_now
LOCAL_SYNTHESIS_SOURCE = "local-synthesis"
SkillKind = Literal["knowledge", "flow_template"]
@dataclass(frozen=True)
class SkillMetadata:
id: str = field(default_factory=lambda: uuid4().hex)
name: str = ""
description: str = ""
kind: SkillKind = "flow_template"
tags: list[str] = field(default_factory=list)
source: str = LOCAL_SYNTHESIS_SOURCE
version: int = 1
parent_version_id: str | None = None
originating_goal: str | None = None
created_at: datetime = field(default_factory=utc_now)
updated_at: datetime = field(default_factory=utc_now)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"description": self.description,
"kind": self.kind,
"tags": list(self.tags),
"source": self.source,
"version": self.version,
"parent_version_id": self.parent_version_id,
"originating_goal": self.originating_goal,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SkillMetadata":
return cls(
id=str(data.get("id") or uuid4().hex),
name=str(data.get("name") or ""),
description=str(data.get("description") or ""),
kind=data.get("kind") or "flow_template",
tags=[str(tag) for tag in data.get("tags", [])],
source=str(data.get("source") or LOCAL_SYNTHESIS_SOURCE),
version=int(data.get("version") or 1),
parent_version_id=data.get("parent_version_id"),
originating_goal=data.get("originating_goal"),
created_at=_parse_datetime(data.get("created_at")),
updated_at=_parse_datetime(data.get("updated_at")),
)
@dataclass(frozen=True)
class FlowStep:
tool_name: str
args: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"tool_name": self.tool_name,
"args": dict(self.args),
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "FlowStep":
return cls(
tool_name=str(data.get("tool_name") or data.get("action") or ""),
args=dict(data.get("args") or {}),
)
@dataclass(frozen=True)
class Skill:
metadata: SkillMetadata
@property
def id(self) -> str:
return self.metadata.id
@property
def name(self) -> str:
return self.metadata.name
@property
def description(self) -> str:
return self.metadata.description
@property
def source(self) -> str:
return self.metadata.source
@property
def version(self) -> int:
return self.metadata.version
@property
def parent_version_id(self) -> str | None:
return self.metadata.parent_version_id
@property
def originating_goal(self) -> str | None:
return self.metadata.originating_goal
@dataclass(frozen=True)
class FlowTemplateSkill(Skill):
steps: list[FlowStep] = field(default_factory=list)
parameters: dict[str, dict[str, Any]] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
**self.metadata.to_dict(),
"steps": [step.to_dict() for step in self.steps],
"parameters": {
name: dict(schema)
for name, schema in self.parameters.items()
},
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "FlowTemplateSkill":
return cls(
metadata=SkillMetadata.from_dict(data),
steps=[
FlowStep.from_dict(step)
for step in data.get("steps", [])
],
parameters={
str(name): dict(schema)
for name, schema in (data.get("parameters") or {}).items()
},
)
def with_metadata(self, **changes: Any) -> "FlowTemplateSkill":
return replace(self, metadata=replace(self.metadata, **changes))
def with_updates(
self,
*,
steps: list[FlowStep] | None = None,
parameters: dict[str, dict[str, Any]] | None = None,
**metadata_changes: Any,
) -> "FlowTemplateSkill":
metadata = replace(
self.metadata,
updated_at=utc_now(),
**metadata_changes,
)
return replace(
self,
metadata=metadata,
steps=list(steps) if steps is not None else list(self.steps),
parameters={
name: dict(schema)
for name, schema in (
parameters if parameters is not None else self.parameters
).items()
},
)
def skill_embedding_text(skill: FlowTemplateSkill) -> str:
goal = skill.originating_goal or ""
return f"{skill.name}: {skill.description}\nOriginal goal: {goal}"
def clone_skill(skill: FlowTemplateSkill) -> FlowTemplateSkill:
return FlowTemplateSkill.from_dict(skill.to_dict())
def _parse_datetime(value: Any) -> datetime:
if isinstance(value, datetime):
return value
if isinstance(value, str):
try:
return datetime.fromisoformat(value)
except ValueError:
pass
return utc_now()
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
import math
from dataclasses import dataclass
from skills_learning.config import SkillAuthoringConfig, load_config
from skills_learning.embeddings import EmbeddingClient, embed_skill_text
from skills_learning.models import FlowTemplateSkill
from skills_learning.store import SkillStore, get_default_store
@dataclass(frozen=True)
class ScoredSkill:
skill: FlowTemplateSkill
score: float
def retrieve_candidate_skills(
goal: str,
*,
store: SkillStore | None = None,
top_k: int | None = None,
embedding_client: EmbeddingClient | None = None,
config: SkillAuthoringConfig | None = None,
) -> list[ScoredSkill]:
settings = config or load_config()
skill_store = store or get_default_store()
query_vector = embed_skill_text(
goal,
client=embedding_client,
config=settings,
)
if query_vector is None:
return []
scored: list[ScoredSkill] = []
for record in skill_store.list_embeddings():
skill = skill_store.get_by_id(record.skill_id)
if skill is None:
continue
score = _cosine_similarity(query_vector, record.vector)
scored.append(ScoredSkill(skill=skill, score=score))
scored.sort(key=lambda item: item.score, reverse=True)
return scored[: top_k or settings.top_k]
def _cosine_similarity(left: list[float], right: list[float]) -> float:
if not left or not right or len(left) != len(right):
return 0.0
dot = sum(a * b for a, b in zip(left, right, strict=True))
left_norm = math.sqrt(sum(value * value for value in left))
right_norm = math.sqrt(sum(value * value for value in right))
if left_norm == 0 or right_norm == 0:
return 0.0
return dot / (left_norm * right_norm)
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
from dataclasses import dataclass, field, replace
from datetime import datetime
from typing import Any
from uuid import uuid4
from core.models import utc_now
from skills_learning.models import (
LOCAL_SYNTHESIS_SOURCE,
FlowTemplateSkill,
clone_skill,
)
@dataclass(frozen=True)
class SkillEmbeddingRecord:
skill_id: str
version: int
vector: list[float]
model_name: str
updated_at: datetime = field(default_factory=utc_now)
def to_dict(self) -> dict[str, Any]:
return {
"skill_id": self.skill_id,
"version": self.version,
"vector": list(self.vector),
"model_name": self.model_name,
"updated_at": self.updated_at.isoformat(),
}
class SkillStore:
def __init__(self) -> None:
self._skills: dict[str, FlowTemplateSkill] = {}
self._embeddings: dict[tuple[str, int], SkillEmbeddingRecord] = {}
def create_version(
self,
skill: FlowTemplateSkill,
*,
parent: FlowTemplateSkill | None = None,
) -> FlowTemplateSkill:
version = parent.version + 1 if parent else self._next_version(skill.name)
stored = skill.with_metadata(
id=uuid4().hex,
source=LOCAL_SYNTHESIS_SOURCE,
version=version,
parent_version_id=parent.id if parent else skill.parent_version_id,
created_at=utc_now(),
updated_at=utc_now(),
)
self._skills[stored.id] = clone_skill(stored)
return clone_skill(stored)
def update_skill(self, skill: FlowTemplateSkill) -> FlowTemplateSkill:
if skill.id not in self._skills:
raise KeyError(f"unknown skill {skill.id}")
stored = skill.with_metadata(
source=LOCAL_SYNTHESIS_SOURCE,
updated_at=utc_now(),
)
self._skills[stored.id] = clone_skill(stored)
return clone_skill(stored)
def get_by_id(self, skill_id: str) -> FlowTemplateSkill | None:
skill = self._skills.get(skill_id)
return clone_skill(skill) if skill else None
def get_latest_by_name(self, name: str) -> FlowTemplateSkill | None:
versions = self.list_versions(name)
return versions[-1] if versions else None
def list_versions(self, name: str) -> list[FlowTemplateSkill]:
return sorted(
[
clone_skill(skill)
for skill in self._skills.values()
if skill.name == name
],
key=lambda skill: skill.version,
)
def list_latest(self) -> list[FlowTemplateSkill]:
latest: dict[str, FlowTemplateSkill] = {}
for skill in self._skills.values():
current = latest.get(skill.name)
if current is None or skill.version > current.version:
latest[skill.name] = skill
return [clone_skill(skill) for skill in latest.values()]
def list_all(self) -> list[FlowTemplateSkill]:
return [clone_skill(skill) for skill in self._skills.values()]
def store_embedding(
self,
skill: FlowTemplateSkill,
vector: list[float],
*,
model_name: str,
) -> SkillEmbeddingRecord:
record = SkillEmbeddingRecord(
skill_id=skill.id,
version=skill.version,
vector=[float(value) for value in vector],
model_name=model_name,
)
self._embeddings[(record.skill_id, record.version)] = record
return replace(record, vector=list(record.vector))
def get_embedding(
self,
skill_id: str,
version: int,
) -> SkillEmbeddingRecord | None:
record = self._embeddings.get((skill_id, version))
return replace(record, vector=list(record.vector)) if record else None
def list_embeddings(self) -> list[SkillEmbeddingRecord]:
return [
replace(record, vector=list(record.vector))
for record in self._embeddings.values()
]
def _next_version(self, name: str) -> int:
latest = self.get_latest_by_name(name)
return latest.version + 1 if latest else 1
_DEFAULT_STORE = SkillStore()
def get_default_store() -> SkillStore:
return _DEFAULT_STORE
def reset_default_store() -> SkillStore:
global _DEFAULT_STORE
_DEFAULT_STORE = SkillStore()
return _DEFAULT_STORE
+248
View File
@@ -0,0 +1,248 @@
from __future__ import annotations
import re
from collections.abc import Iterable
from typing import Any
from uuid import uuid4
from skills_learning.models import (
LOCAL_SYNTHESIS_SOURCE,
FlowStep,
FlowTemplateSkill,
SkillMetadata,
)
from skills_learning.store import SkillStore
READ_ONLY_TOOL_NAMES = {
"describe_screen",
"describe_screen_semantic",
"screenshot",
"take_screenshot",
"ui_tree",
"get_ui_tree",
"find_text",
"find_text_on_screen",
"find_icon",
"find_icon_on_screen",
}
def extract_tool_calls(
task_id: str,
timeline: Any,
) -> list[FlowStep]:
records = _timeline_records(timeline, task_id)
steps: list[FlowStep] = []
for record in records:
tool_call = _record_value(record, "tool_call")
if not isinstance(tool_call, dict):
continue
tool_name = str(tool_call.get("action") or tool_call.get("tool_name") or "")
if not tool_name or tool_name in READ_ONLY_TOOL_NAMES:
continue
steps.append(FlowStep(tool_name=tool_name, args=dict(tool_call.get("args") or {})))
return steps
def find_matching_skeleton(
steps: list[FlowStep],
store: SkillStore | None,
) -> FlowTemplateSkill | None:
if store is None:
return None
sequence = _tool_sequence(steps)
for skill in store.list_latest():
if _tool_sequence(skill.steps) == sequence:
return skill
return None
def synthesize_flow_skill(
goal: str,
timeline: Any,
*,
task_id: str = "",
store: SkillStore | None = None,
) -> FlowTemplateSkill:
records = _timeline_records(timeline, task_id)
executed_steps = extract_tool_calls(task_id, records)
matched = find_matching_skeleton(executed_steps, store)
if matched is None:
steps = executed_steps
parameters: dict[str, dict[str, Any]] = {}
name = _skill_name_from_goal(goal)
else:
steps, parameters = promote_parameters(
matched.steps,
executed_steps,
records=records,
existing_parameters=matched.parameters,
)
name = matched.name
return FlowTemplateSkill(
metadata=SkillMetadata(
id=uuid4().hex,
name=name,
description=f"Learned flow for: {goal}",
kind="flow_template",
source=LOCAL_SYNTHESIS_SOURCE,
version=matched.version if matched else 1,
parent_version_id=matched.parent_version_id if matched else None,
originating_goal=goal,
),
steps=steps,
parameters=parameters,
)
def promote_parameters(
stored_steps: list[FlowStep],
executed_steps: list[FlowStep],
*,
records: Iterable[Any] = (),
existing_parameters: dict[str, dict[str, Any]] | None = None,
) -> tuple[list[FlowStep], dict[str, dict[str, Any]]]:
parameters = {
name: dict(schema)
for name, schema in (existing_parameters or {}).items()
}
parameterized_steps = [
FlowStep(step.tool_name, dict(step.args))
for step in executed_steps
]
used_names = set(parameters)
parameter_index = len(used_names) + 1
for step_index, (stored_step, executed_step) in enumerate(
zip(stored_steps, executed_steps, strict=False)
):
for arg_name, executed_value in executed_step.args.items():
stored_value = stored_step.args.get(arg_name)
if stored_value == executed_value:
continue
if _is_placeholder(stored_value):
parameterized_steps[step_index].args[arg_name] = stored_value
continue
if _is_placeholder(executed_value):
continue
preferred_name = _parameter_name_from_semantic_record(
list(records),
step_index,
)
parameter_name = _unique_parameter_name(
preferred_name or f"param_{parameter_index}",
used_names,
)
used_names.add(parameter_name)
parameter_index += 1
parameterized_steps[step_index].args[arg_name] = f"{{{parameter_name}}}"
parameters[parameter_name] = _parameter_schema(
arg_name,
executed_value,
preferred_name is not None,
)
return parameterized_steps, parameters
def _timeline_records(timeline: Any, task_id: str) -> list[Any]:
if isinstance(timeline, list):
return list(timeline)
if hasattr(timeline, "read"):
return list(timeline.read(task_id))
return list(timeline)
def _record_value(record: Any, key: str) -> Any:
if isinstance(record, dict):
return record.get(key)
return getattr(record, key, None)
def _tool_sequence(steps: list[FlowStep]) -> list[str]:
return [step.tool_name for step in steps]
def _is_placeholder(value: Any) -> bool:
return isinstance(value, str) and value.startswith("{") and value.endswith("}")
def _parameter_schema(
arg_name: str,
value: Any,
from_semantic_label: bool,
) -> dict[str, Any]:
return {
"type": _json_type(value),
"description": (
f"Value for {arg_name} inferred from a semantic widget label"
if from_semantic_label
else f"Value for {arg_name}"
),
}
def _json_type(value: Any) -> str:
if isinstance(value, bool):
return "boolean"
if isinstance(value, int | float):
return "number"
if isinstance(value, list):
return "array"
if isinstance(value, dict):
return "object"
return "string"
def _parameter_name_from_semantic_record(
records: list[Any],
step_index: int,
) -> str | None:
if step_index >= len(records):
return None
result = _record_value(records[step_index], "result")
semantic_scene = _semantic_scene_payload(result)
if not isinstance(semantic_scene, dict):
return None
widgets = semantic_scene.get("widgets")
if not isinstance(widgets, list):
return None
for widget in widgets:
if isinstance(widget, dict) and widget.get("purpose"):
return _sanitize_name(str(widget["purpose"]))
return None
def _semantic_scene_payload(result: Any) -> Any:
if not isinstance(result, dict):
return None
if "semantic_scene" in result:
return result["semantic_scene"]
nested = result.get("result")
if isinstance(nested, dict):
return nested.get("semantic_scene")
return None
def _unique_parameter_name(name: str, used_names: set[str]) -> str:
candidate = _sanitize_name(name) or "param"
if candidate not in used_names:
return candidate
index = 2
while f"{candidate}_{index}" in used_names:
index += 1
return f"{candidate}_{index}"
def _sanitize_name(value: str) -> str:
sanitized = re.sub(r"[^0-9a-zA-Z]+", "_", value.strip().lower()).strip("_")
if sanitized and sanitized[0].isdigit():
return f"param_{sanitized}"
return sanitized
def _skill_name_from_goal(goal: str) -> str:
return goal.strip() or "learned flow"
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from skills_learning.models import FlowStep, FlowTemplateSkill
from skills_learning.store import SkillStore
@dataclass(frozen=True)
class VersionDiff:
structural_divergence: bool
stored_sequence: list[str]
executed_sequence: list[str]
argument_differences: list[tuple[int, str, Any, Any]]
@dataclass(frozen=True)
class VersioningResult:
skill: FlowTemplateSkill
created_new_version: bool
def diff_flow_versions(
stored_steps: list[FlowStep],
executed_steps: list[FlowStep],
) -> VersionDiff:
stored_sequence = [step.tool_name for step in stored_steps]
executed_sequence = [step.tool_name for step in executed_steps]
structural = stored_sequence != executed_sequence
differences: list[tuple[int, str, Any, Any]] = []
if not structural:
for index, (stored_step, executed_step) in enumerate(
zip(stored_steps, executed_steps, strict=True)
):
keys = set(stored_step.args) | set(executed_step.args)
for key in sorted(keys):
stored_value = stored_step.args.get(key)
executed_value = executed_step.args.get(key)
if stored_value != executed_value:
differences.append((index, key, stored_value, executed_value))
return VersionDiff(
structural_divergence=structural,
stored_sequence=stored_sequence,
executed_sequence=executed_sequence,
argument_differences=differences,
)
def store_synthesized_skill(
store: SkillStore,
candidate: FlowTemplateSkill,
) -> VersioningResult:
latest = store.get_latest_by_name(candidate.name)
if latest is None:
return VersioningResult(store.create_version(candidate), True)
diff = diff_flow_versions(latest.steps, candidate.steps)
if diff.structural_divergence:
return VersioningResult(store.create_version(candidate, parent=latest), True)
merged_parameters = {
**latest.parameters,
**candidate.parameters,
}
updated = latest.with_updates(
steps=candidate.steps,
parameters=merged_parameters,
description=candidate.description,
originating_goal=candidate.originating_goal,
)
return VersioningResult(store.update_skill(updated), False)
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
from skills_learning.config import SkillAuthoringConfig
from skills_learning.embeddings import embed_skill_text
from skills_learning.models import FlowStep, FlowTemplateSkill, SkillMetadata
from skills_learning.retrieval import retrieve_candidate_skills
from skills_learning.store import SkillStore
class FakeEmbeddingClient:
def __init__(self, *, fail: bool = False) -> None:
self.fail = fail
def embed(self, text: str, *, model: str) -> list[float]:
if self.fail:
raise TimeoutError("embedding timeout")
lowered = text.lower()
if "coffee" in lowered:
return [1.0, 0.0]
if "tea" in lowered:
return [0.0, 1.0]
return [0.5, 0.5]
def _skill(name: str, goal: str) -> FlowTemplateSkill:
return FlowTemplateSkill(
metadata=SkillMetadata(
name=name,
description=f"Learned flow for {goal}",
originating_goal=goal,
),
steps=[FlowStep("input_text", {"text": goal})],
parameters={},
)
def test_embed_skill_text_degrades_to_none_on_provider_failure() -> None:
result = embed_skill_text(
"coffee",
client=FakeEmbeddingClient(fail=True),
config=SkillAuthoringConfig(enabled=True),
)
assert result is None
def test_retrieve_candidate_skills_ranks_by_similarity_and_truncates_top_k() -> None:
store = SkillStore()
coffee = store.create_version(_skill("search coffee", "search coffee"))
tea = store.create_version(_skill("search tea", "search tea"))
store.store_embedding(coffee, [1.0, 0.0], model_name="fake")
store.store_embedding(tea, [0.0, 1.0], model_name="fake")
results = retrieve_candidate_skills(
"find coffee",
store=store,
top_k=1,
embedding_client=FakeEmbeddingClient(),
config=SkillAuthoringConfig(enabled=True),
)
assert [result.skill.name for result in results] == ["search coffee"]
def test_retrieve_candidate_skills_returns_empty_without_embeddings() -> None:
store = SkillStore()
store.create_version(_skill("search coffee", "search coffee"))
results = retrieve_candidate_skills(
"find coffee",
store=store,
embedding_client=FakeEmbeddingClient(),
config=SkillAuthoringConfig(enabled=True),
)
assert results == []
def test_skill_without_embedding_is_stored_but_excluded_from_retrieval() -> None:
store = SkillStore()
skill = store.create_version(_skill("search coffee", "search coffee"))
results = retrieve_candidate_skills(
"find coffee",
store=store,
embedding_client=FakeEmbeddingClient(),
config=SkillAuthoringConfig(enabled=True),
)
assert store.get_by_id(skill.id) == skill
assert results == []
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
import sys
from skills_learning.models import (
LOCAL_SYNTHESIS_SOURCE,
FlowStep,
FlowTemplateSkill,
SkillMetadata,
)
from skills_learning.store import SkillStore
def _skill(
*,
name: str = "search web",
steps: list[FlowStep] | None = None,
parameters: dict[str, dict[str, object]] | None = None,
) -> FlowTemplateSkill:
return FlowTemplateSkill(
metadata=SkillMetadata(
name=name,
description=f"Learned flow for {name}",
source="external-source",
originating_goal=name,
),
steps=steps or [FlowStep("input_text", {"text": "coffee"})],
parameters=parameters or {},
)
def test_skill_store_enforces_local_synthesis_source_without_catalog_write_path() -> None:
store = SkillStore()
stored = store.create_version(_skill())
assert stored.source == LOCAL_SYNTHESIS_SOURCE
assert "skills.catalog" not in sys.modules
def test_skill_store_keeps_version_chain_and_returns_latest_by_name() -> None:
store = SkillStore()
first = store.create_version(_skill())
second = store.create_version(
_skill(steps=[FlowStep("input_text", {"text": "tea"})]),
parent=first,
)
assert first.version == 1
assert second.version == 2
assert second.parent_version_id == first.id
assert store.get_latest_by_name("search web") == second
assert store.get_by_id(first.id) == first
assert [skill.version for skill in store.list_versions("search web")] == [1, 2]
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
from skills_learning.models import FlowStep, FlowTemplateSkill, SkillMetadata
from skills_learning.store import SkillStore
from skills_learning.synthesis import extract_tool_calls, synthesize_flow_skill
def _record(
action: str,
args: dict[str, object] | None = None,
*,
result: dict[str, object] | None = None,
) -> dict[str, object]:
return {
"tool_call": {
"action": action,
"args": args or {},
},
"result": result or {},
}
def test_extract_tool_calls_filters_read_only_tools_in_order() -> None:
records = [
_record("describe_screen"),
_record("launch_app", {"app_id": "com.example"}),
_record("screenshot"),
_record("tap", {"x": 1, "y": 2}),
_record("find_text", {"query": "Send"}),
_record("input_text", {"text": "coffee"}),
]
steps = extract_tool_calls("", records)
assert steps == [
FlowStep("launch_app", {"app_id": "com.example"}),
FlowStep("tap", {"x": 1, "y": 2}),
FlowStep("input_text", {"text": "coffee"}),
]
def test_first_time_synthesis_has_literal_steps_and_no_parameters() -> None:
skill = synthesize_flow_skill(
"search coffee",
[_record("input_text", {"text": "coffee"})],
)
assert skill.name == "search coffee"
assert skill.steps == [FlowStep("input_text", {"text": "coffee"})]
assert skill.parameters == {}
def test_second_execution_promotes_differing_argument_to_parameter() -> None:
store = SkillStore()
store.create_version(
FlowTemplateSkill(
metadata=SkillMetadata(
name="search",
description="Learned search",
originating_goal="search coffee",
),
steps=[FlowStep("input_text", {"text": "coffee"})],
parameters={},
)
)
records = [
_record(
"input_text",
{"text": "tea"},
result={
"semantic_scene": {
"page": "Search",
"intents": ["search"],
"widgets": [
{
"element_id": "search-input",
"purpose": "search field",
}
],
}
},
)
]
skill = synthesize_flow_skill("search tea", records, store=store)
assert skill.name == "search"
assert skill.steps == [FlowStep("input_text", {"text": "{search_field}"})]
assert "search_field" in skill.parameters
def test_identical_repeat_does_not_add_parameters() -> None:
store = SkillStore()
store.create_version(
FlowTemplateSkill(
metadata=SkillMetadata(
name="search",
description="Learned search",
originating_goal="search coffee",
),
steps=[FlowStep("input_text", {"text": "coffee"})],
parameters={},
)
)
skill = synthesize_flow_skill(
"search coffee again",
[_record("input_text", {"text": "coffee"})],
store=store,
)
assert skill.steps == [FlowStep("input_text", {"text": "coffee"})]
assert skill.parameters == {}
+217
View File
@@ -0,0 +1,217 @@
from __future__ import annotations
from core.models import Bounds, Scene, SceneElement, Task
from runtime.executor import Executor, ExecutorConfig
from runtime.planner import PlannedStep, Planner
from runtime.task import TaskRunner, TaskRunnerConfig
from skills_learning.config import SkillAuthoringConfig
from skills_learning.retrieval import retrieve_candidate_skills
from skills_learning.store import SkillStore
from storage.artifact_store import ArtifactStore
from storage.timeline import Timeline
from tests.fakes import PNG_10X20
class ScriptedPlanner(Planner):
def __init__(self, steps: list[PlannedStep]) -> None:
self.steps = steps
def plan(self, *, goal, scene, context):
if len(context.step_results) >= len(self.steps):
return []
return [self.steps[len(context.step_results)]]
def goal_reached(self, *, goal, scene, context):
return len(context.step_results) >= len(self.steps) and all(
result.success for result in context.step_results
)
class FakeEmbeddingClient:
def __init__(self, *, fail: bool = False) -> None:
self.fail = fail
def embed(self, text: str, *, model: str) -> list[float]:
if self.fail:
raise TimeoutError("embedding timeout")
lowered = text.lower()
if "coffee" in lowered:
return [1.0, 0.0]
if "tea" in lowered:
return [0.0, 1.0]
return [0.5, 0.5]
def _scene() -> Scene:
return Scene(
width=10,
height=20,
elements=[
SceneElement(
id="input",
type="input",
text="Search",
bounds=Bounds(1, 2, 3, 4),
)
],
)
def _runner(
*,
tmp_path,
planner: Planner,
timeline: Timeline | None = None,
store: SkillStore | None = None,
skill_config: SkillAuthoringConfig | None = None,
embedding_client: FakeEmbeddingClient | None = None,
on_task_succeeded=None,
) -> TaskRunner:
return TaskRunner(
planner=planner,
executor=Executor(
tools={"input_text": lambda **kwargs: {"ok": True, **kwargs}},
config=ExecutorConfig(max_retries=1, backoff_seconds=0),
),
timeline=timeline or Timeline(ArtifactStore(tmp_path / "history")),
config=TaskRunnerConfig(max_steps=5),
observer=lambda device_id: _scene(),
screenshot_provider=lambda device_id: PNG_10X20,
skill_store=store,
skill_authoring_config=skill_config,
skill_embedding_client=embedding_client,
on_task_succeeded=on_task_succeeded,
)
def test_task_runner_calls_explicit_success_hook_once(tmp_path) -> None:
calls: list[tuple[str, str, Timeline]] = []
timeline = Timeline(ArtifactStore(tmp_path / "history"))
runner = _runner(
tmp_path=tmp_path,
timeline=timeline,
planner=ScriptedPlanner(
[PlannedStep("input_text", "type", {"text": "coffee"})]
),
on_task_succeeded=lambda task_id, goal, timeline: calls.append(
(task_id, goal, timeline)
),
)
task = Task(goal="search coffee", device_id="phone")
result = runner.run(task)
assert result.status == "completed"
assert calls == [(task.id, "search coffee", timeline)]
def test_task_runner_skill_authoring_disabled_by_default_writes_no_skill(tmp_path) -> None:
store = SkillStore()
runner = _runner(
tmp_path=tmp_path,
planner=ScriptedPlanner(
[PlannedStep("input_text", "type", {"text": "coffee"})]
),
store=store,
)
result = runner.run(Task(goal="search coffee", device_id="phone"))
assert result.status == "completed"
assert store.list_all() == []
def test_task_runner_skill_authoring_enabled_stores_skill_and_embedding(tmp_path) -> None:
store = SkillStore()
runner = _runner(
tmp_path=tmp_path,
planner=ScriptedPlanner(
[PlannedStep("input_text", "type", {"text": "coffee"})]
),
store=store,
skill_config=SkillAuthoringConfig(enabled=True, embedding_model="fake"),
embedding_client=FakeEmbeddingClient(),
)
result = runner.run(Task(goal="search coffee", device_id="phone"))
assert result.status == "completed"
skill = store.get_latest_by_name("search coffee")
assert skill is not None
assert skill.steps[0].args == {"text": "coffee"}
assert store.get_embedding(skill.id, skill.version) is not None
def test_task_runner_embedding_failure_still_stores_skill_without_embedding(tmp_path) -> None:
store = SkillStore()
runner = _runner(
tmp_path=tmp_path,
planner=ScriptedPlanner(
[PlannedStep("input_text", "type", {"text": "coffee"})]
),
store=store,
skill_config=SkillAuthoringConfig(enabled=True, embedding_model="fake"),
embedding_client=FakeEmbeddingClient(fail=True),
)
result = runner.run(Task(goal="search coffee", device_id="phone"))
assert result.status == "completed"
skill = store.get_latest_by_name("search coffee")
assert skill is not None
assert store.get_embedding(skill.id, skill.version) is None
def test_two_successful_tasks_promote_parameter_and_store_embedding(tmp_path) -> None:
store = SkillStore()
config = SkillAuthoringConfig(enabled=True, embedding_model="fake")
_runner(
tmp_path=tmp_path,
planner=ScriptedPlanner(
[PlannedStep("input_text", "type coffee", {"text": "coffee"})]
),
store=store,
skill_config=config,
embedding_client=FakeEmbeddingClient(),
).run(Task(goal="search coffee", device_id="phone"))
_runner(
tmp_path=tmp_path,
planner=ScriptedPlanner(
[PlannedStep("input_text", "type tea", {"text": "tea"})]
),
store=store,
skill_config=config,
embedding_client=FakeEmbeddingClient(),
).run(Task(goal="search tea", device_id="phone"))
skill = store.get_latest_by_name("search coffee")
assert skill is not None
assert skill.version == 1
assert skill.steps[0].args == {"text": "{param_1}"}
assert "param_1" in skill.parameters
assert store.get_embedding(skill.id, skill.version) is not None
def test_retrieve_candidate_skills_after_successful_task(tmp_path) -> None:
store = SkillStore()
config = SkillAuthoringConfig(enabled=True, embedding_model="fake")
_runner(
tmp_path=tmp_path,
planner=ScriptedPlanner(
[PlannedStep("input_text", "type coffee", {"text": "coffee"})]
),
store=store,
skill_config=config,
embedding_client=FakeEmbeddingClient(),
).run(Task(goal="search coffee", device_id="phone"))
results = retrieve_candidate_skills(
"find coffee",
store=store,
embedding_client=FakeEmbeddingClient(),
config=config,
)
assert [result.skill.name for result in results] == ["search coffee"]
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
from skills_learning.models import FlowStep, FlowTemplateSkill, SkillMetadata
from skills_learning.store import SkillStore
from skills_learning.versioning import diff_flow_versions, store_synthesized_skill
def _skill(
*,
name: str = "search",
steps: list[FlowStep] | None = None,
parameters: dict[str, dict[str, object]] | None = None,
) -> FlowTemplateSkill:
return FlowTemplateSkill(
metadata=SkillMetadata(
name=name,
description="Learned search",
originating_goal="search coffee",
),
steps=steps or [FlowStep("input_text", {"text": "coffee"})],
parameters=parameters or {},
)
def test_diff_flow_versions_detects_extra_missing_and_reordered_steps() -> None:
stored = [FlowStep("tap"), FlowStep("input_text")]
assert diff_flow_versions(stored, [FlowStep("tap")]).structural_divergence
assert diff_flow_versions(
stored,
[FlowStep("tap"), FlowStep("input_text"), FlowStep("tap")],
).structural_divergence
assert diff_flow_versions(
stored,
[FlowStep("input_text"), FlowStep("tap")],
).structural_divergence
def test_argument_only_difference_updates_existing_version_without_bump() -> None:
store = SkillStore()
first = store.create_version(_skill())
candidate = _skill(
steps=[FlowStep("input_text", {"text": "{search_query}"})],
parameters={"search_query": {"type": "string"}},
)
result = store_synthesized_skill(store, candidate)
assert result.created_new_version is False
assert result.skill.version == first.version
assert result.skill.id == first.id
assert result.skill.parameters == {"search_query": {"type": "string"}}
assert store.get_latest_by_name("search") == result.skill
def test_structural_divergence_creates_new_version_and_preserves_parent() -> None:
store = SkillStore()
first = store.create_version(_skill())
candidate = _skill(
steps=[
FlowStep("tap", {"x": 1}),
FlowStep("input_text", {"text": "coffee"}),
]
)
result = store_synthesized_skill(store, candidate)
assert result.created_new_version is True
assert result.skill.version == 2
assert result.skill.parent_version_id == first.id
assert store.get_by_id(first.id) == first
assert store.get_latest_by_name("search") == result.skill
+1
View File
@@ -12,6 +12,7 @@ def test_imports_new_packages() -> None:
"perception", "perception",
"runtime", "runtime",
"semantic", "semantic",
"skills_learning",
"storage", "storage",
"tools", "tools",
"world", "world",