Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ Skills are reusable instruction modules. Three sources: predefined skills loaded
and DIAL skill resources (`dial_skills/`) fetched per request from Core's `/v2/skills` API — a folder with `SKILL.md`
plus bundled text files the agent reads on demand via `read_skill(skill_name, file_path)`.
`SkillsRegistry` merges all three per request and owns precedence (predefined > dial-prompt > dial-skill).
A user can also invoke one of their own skills from a message (`skill_invocation/`, preview): the
`custom_content.skills[*]` chips are resolved per request, registered ahead of every agent source, and
injected as a synthetic `read_skill` pair. See [`docs/skills.md`](docs/skills.md).

### Configuration Model

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ Controls which tool-execution stages are surfaced in the DIAL UI for each app. S
| `DIAL_SKILLS_FILE_MAX_BYTES` | `262144` | No | Cap on a single file read from a DIAL skill resource, `SKILL.md` included. Must exceed the largest manifest you expect: an over-cap manifest drops the skill. See [docs/skills.md](docs/skills.md). |
| `DIAL_SKILLS_MAX_FILES` | `200` | No | Maximum bundled files advertised to the agent per DIAL skill resource; beyond it the listing is truncated |
| `DIAL_SKILLS_LISTING_MAX_PAGES` | `10` | No | Maximum file-listing pages followed per DIAL skill resource, bounding a server-supplied cursor |
| `SKILL_INVOCATION_MAX_SKILLS` | `10` | No | Maximum distinct skills a user may have invoked from the messages of one conversation (`custom_content.skills`), counted newest first. Each one adds a `<skill>` block to the system prompt and one DIAL Core fetch per turn; beyond the cap the oldest picks stop being registered. Preview-gated. See [docs/skills.md](docs/skills.md). |
| **Feature Gating** | | | |
| `ENABLE_PREVIEW_FEATURES` | `false` | No | Enable preview features across the deployment (schema visibility + runtime activation) |
| **Templates** | | | |
Expand Down
643 changes: 643 additions & 0 deletions docs/designs/skill_invocation.md

Large diffs are not rendered by default.

79 changes: 79 additions & 0 deletions docs/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,85 @@ For design details, see [the design doc](designs/skills_as_dial_resource.md).

---

## Invoking a Skill from a Message (preview)

A user can bring **their own** skill into a conversation with an agent they do not own. The client
puts the picked skill on the user message; QuickApps loads it for that turn and keeps it readable for
the rest of the conversation. The model does not get to choose — a picked skill is always loaded.

### Wire Contract

```jsonc
{
"role": "user",
"content": "/code-review focus on auth",
"custom_content": {
"skills": [{ "url": "skills/<bucket>/code-review" }]
}
}
```

- `url` is the only field QuickApps reads; anything else the client adds (a title, say) is ignored.
- The URL carries **no trailing slash** — DIAL Core shares exactly the URL it is given.
- The field is **per message**, not per request: it stays on the turn it was picked on, which is what
lets Core re-share the skill on every later turn and keeps the bundled files readable.
- `content` is opaque. QuickApps never parses it, so the `/name` token is just text.
- **One skill per message.** The field is an array, but only the first entry is loaded; any others
are ignored and reported as a warning in the initialization issues stage.
- `custom_content.skills` on a non-user message is ignored.
- DIAL Core auto-shares each referenced skill to the application's per-request key, and rejects the
whole request with `400` for a malformed entry or a non-`skills/` URL, and with `403` for a skill
the user cannot read.

### Behavior

- Each picked skill is resolved like a `dial-skill` and registered under **its own manifest name**,
so `<available_skills>`, `read_skill` and bundled-file reads all work unchanged.
- A synthetic `read_skill` call and result pair is inserted at the head of the conversation, right
after the first user message — the same place the built-in file-transfer skill goes — so the
instructions read ahead of the turns they apply to. The user sees the normal
"Reading Skill: `<name>`" stage — the invocation is something they did explicitly, so unlike
other synthetic injections it is not hidden.
- The injection runs only on the turn the pick is made. A pick made on the **first** user message
of a conversation is persisted with the rest of that turn's tool history, so on later turns the
pair comes back from the assistant state like any other tool result and `read_skill` is never
re-run for it.
- A pick made on a **later** message is not persisted: only what follows the last user message is
stored, and the pair is inserted ahead of that point. The manifest is in context for that turn
only. The skill stays listed in `<available_skills>` and its files stay readable, but the model
has to call `read_skill` itself to see the manifest again. Known gap for phase 1a.
- The model therefore keeps the manifest it saw when the skill was picked, even if the user edits
the skill afterwards. A later read of a **bundled file** returns the current file.
- A picked skill **wins** a name collision with one of the agent's skills, which is then dropped and
reported in the initialization issues stage. Picked skills are expected to have names that do not
clash with the agent's; nothing enforces it yet.
- A chip that cannot be loaded gets an error result naming the skill, so the model can say so, and
the reason is reported to the user. The request is still served.
- The field is stripped from the working messages before they reach the orchestrator or any DIAL
deployment tool, whether or not preview features are enabled.

### Limits

| Variable | Default | Purpose |
|---|---|---|
| `SKILL_INVOCATION_MAX_SKILLS` | `10` | Distinct picked skills resolved and listed per request across the conversation, newest first. Each one adds a `<skill>` block to the system prompt and one DIAL Core fetch per turn. |
| `DIAL_SKILLS_FILE_MAX_BYTES` | `262144` | Reused unchanged for the manifest and each bundled file. |

Beyond the cap the **oldest** picks stop being registered: their manifests stay in the history, but
their names no longer resolve.

### Limitations

- Preview-gated: with `ENABLE_PREVIEW_FEATURES=false` a chip is neither resolved nor injected.
- The user can only pick skills from their own catalog — the agent's own skills are not offered.
- Only `skills/` resources can be picked; a declared `prompts/` skill is used by the model as today.
- A skill that was shared with the user and later unshared makes every later turn of that
conversation fail with `403` in DIAL Core, before the request reaches QuickApps.

For design details, see [the design doc](designs/skill_invocation.md).

---

## Migrating from Agent Instructions

The `config/predefined/instructions/` directory convention and `AgentInstructionsProvider` have been removed. The skills
Expand Down
2 changes: 2 additions & 0 deletions src/quickapp/app_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
)
from quickapp.rest_api_tooling import RestApiToolingModule
from quickapp.shared import shared_module
from quickapp.skill_invocation import SkillInvocationModule
from quickapp.skills.skills_module import SkillsModule
from quickapp.starters.starters_module import StartersModule
from quickapp.timestamp_tooling.timestamp_module import TimestampModule
Expand Down Expand Up @@ -60,6 +61,7 @@ def build_di_modules() -> list[Module]:
SkillsModule(),
DialPromptSkillsModule(),
DialSkillsModule(),
SkillInvocationModule(),
TimestampModule(),
AgentHooksModule(),
DialFilesToolingModule(),
Expand Down
1 change: 1 addition & 0 deletions src/quickapp/common/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
DIAL_BEARER,
EXTERNAL_TOOL_NAMES,
ORCHESTRATOR_AZURE_CLIENT,
REQUEST_MESSAGES,
RESPONSE_FORMAT,
TOOL_CHOICE,
ACCEPT_LANGUAGE,
Expand Down
5 changes: 4 additions & 1 deletion src/quickapp/common/_di_types.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Annotated

from aidial_sdk.chat_completion import ResponseFormat
from aidial_sdk.chat_completion import Message, ResponseFormat
from aidial_sdk.chat_completion.request import ToolChoice
from openai.lib.azure import AsyncAzureOpenAI
from pydantic import SecretStr
Expand All @@ -16,3 +16,6 @@
ORCHESTRATOR_AZURE_CLIENT = Annotated[AsyncAzureOpenAI, "ORCHESTRATOR_AZURE_CLIENT"]
DEPLOYMENT_AZURE_CLIENT = Annotated[AsyncAzureOpenAI, "DEPLOYMENT_AZURE_CLIENT"]
ACCEPT_LANGUAGE = Annotated[str | None, "ACCEPT_LANGUAGE"]
# Raw request messages, as they arrived. Available to initializers, which run
# before `_RequestContextSetup.setup_messages` populates `context.messages`.
REQUEST_MESSAGES = Annotated[list[Message], "REQUEST_MESSAGES"]
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ class StagedToolSyntheticInjector(SyntheticToolCallInjector, ABC):
"""Provides `get_content` by locating a `StagedBaseTool` by its sanitized
OpenAI function name and calling `tool.arun()` with the declared arguments."""

stage_level: StageDisplayLevel = StageDisplayLevel.DEBUG
"""How visible the injected call's stage is. Defaults to DEBUG, which hides it:
an injection the user did not ask for should not look like work they requested.
A subclass acting on an explicit user action overrides it with INFO."""

@inject
def __init__(
self,
Expand All @@ -41,7 +46,5 @@ async def get_content(self, messages: list[Message]) -> str | None:
)
return None
arguments = await self.get_arguments()
result = await tool.arun(
_ARUN_SYNTHETIC_CALL_ID, stage_level=StageDisplayLevel.DEBUG, **arguments
)
result = await tool.arun(_ARUN_SYNTHETIC_CALL_ID, stage_level=self.stage_level, **arguments)
return result.content
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,10 @@ def handle_initialization_issues(self) -> None:
tool_lines.append(fenced_code_block(exc.details))
elif isinstance(exc, SkillCatastrophicInitializationException):
catastrophic_lines.append(f"- {exc.reason}")
elif isinstance(exc, SkillInitializationException) and exc.url is not None:
line = f"- **{exc.url}**: {exc.reason}"
elif isinstance(exc, SkillInitializationException):
# A skill issue that belongs to the message rather than to one URL
# (e.g. more skills invoked than a message may carry) has no url.
line = f"- {exc.reason}" if exc.url is None else f"- **{exc.url}**: {exc.reason}"
if exc.severity == "warning":
per_url_warning_lines.append(line)
else:
Expand Down
14 changes: 14 additions & 0 deletions src/quickapp/core/application/_request_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
CLIENT_CHANNEL_ID,
DIAL_API_KEY,
DIAL_BEARER,
REQUEST_MESSAGES,
TOOL_CHOICE,
ForwardedHeaders,
)
Expand Down Expand Up @@ -57,6 +58,19 @@ class _RequestContext(MessagesMixin):
_tool_choice: TOOL_CHOICE = None
_extra_tools: list[Tool] | None = None
_accept_language: ACCEPT_LANGUAGE = None
_request_messages: REQUEST_MESSAGES | None = None

@property
def request_messages(self) -> REQUEST_MESSAGES:
Comment thread
andrii-novikov marked this conversation as resolved.
"""Raw request messages, readable by initializers before
``setup_messages`` populates the transformed ``messages``."""
return self._request_messages if self._request_messages is not None else []

@request_messages.setter
def request_messages(self, value: REQUEST_MESSAGES) -> None:
if self._request_messages is not None:
raise RuntimeError("Request messages are already set")
self._request_messages = value

@property
def bearer(self) -> DIAL_BEARER:
Expand Down
1 change: 1 addition & 0 deletions src/quickapp/core/application/_request_context_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ async def setup_context(
)
log_customised_catch_all_strategies(context.application_config)
if isinstance(request, Request):
context.request_messages = request.messages
context.forwarded_headers = extract_x_headers_from_request(request)
context.client_channel_id = _extract_client_channel_id(context.forwarded_headers)
context.accept_language = request.headers.get(self.__proxy_settings.language_header)
Expand Down
5 changes: 5 additions & 0 deletions src/quickapp/core/application/app_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
CLIENT_CHANNEL_ID,
DIAL_API_KEY,
DIAL_BEARER,
REQUEST_MESSAGES,
RESPONSE_FORMAT,
TOOL_CHOICE,
ForwardedHeaders,
Expand Down Expand Up @@ -83,6 +84,10 @@ def __provide_choice(self, context: _RequestContext) -> Choice:
def __provide_response_format(self, context: _RequestContext) -> RESPONSE_FORMAT:
return context.response_format

@multiprovider
def __provide_request_messages(self, context: _RequestContext) -> REQUEST_MESSAGES:
return context.request_messages

@provider
def __provide_tool_choice(self, context: _RequestContext) -> TOOL_CHOICE:
return context.tool_choice
Expand Down
3 changes: 3 additions & 0 deletions src/quickapp/skill_invocation/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from quickapp.skill_invocation.skill_invocation_module import SkillInvocationModule

__all__ = ["SkillInvocationModule"]
69 changes: 69 additions & 0 deletions src/quickapp/skill_invocation/_invoked_skills_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import threading

from quickapp.common.exceptions import InitializationException, SkillInitializationException
from quickapp.skills import ResolvedSkill, SkillsProvider
Comment thread
andrii-novikov marked this conversation as resolved.

_USER_SELECTED_HEADER = "Skill `{name}`, selected by the user for this conversation."


def _mark_user_selected(skill: ResolvedSkill) -> ResolvedSkill:
"""Prepend one line naming the skill as user-selected, so the model can tell it
apart from the agent's own when the user refers to it in words."""
header = _USER_SELECTED_HEADER.format(name=skill.metadata.name)
return skill.model_copy(update={"content": f"{header}\n{skill.content}"})


class _InvokedSkillsContext(SkillsProvider):
"""Request-scoped bag of the skills picked on this conversation's messages,
populated by ``_SkillInvocationInitializer``, and the ``SkillsProvider``
``SkillsRegistry`` consumes for them.

The entries are the skills as resolved — own name, own description, real URL,
files and reader — so everything downstream (the merge, ``generate_skills_xml``,
``read_skill``, bundled files) works unchanged.

``order`` runs ahead of every agent source (agent/predefined ``0``, dial-prompt
``10``, dial-skill ``20``), so a picked skill wins a name collision and the
agent's same-named skill is dropped by the registry's collision path.
"""

order = -10
display_name = "user skills"

def __init__(self) -> None:
self._current_pick_url: str | None = None
self._skills_by_url: dict[str, ResolvedSkill] = {}
self._exceptions: list[InitializationException] = []
self._lock = threading.Lock()

@property
def resolved_skills(self) -> list[ResolvedSkill]:
return list(self._skills_by_url.values())

@property
def exceptions(self) -> list[InitializationException]:
return self._exceptions

@property
def current_pick_url(self) -> str | None:
"""The pick on the message being answered, if this turn made one.

Recorded here rather than re-parsed from the messages later, so the injector
does not care whether the scrub transformer has already run.
"""
return self._current_pick_url

def set_current_pick_url(self, url: str | None) -> None:
self._current_pick_url = url

def set_resolved_skills(self, skills: list[ResolvedSkill]) -> None:
with self._lock:
self._skills_by_url = {skill.url: _mark_user_selected(skill) for skill in skills}

def find_skill(self, url: str) -> ResolvedSkill | None:
"""The skill resolved for *url*, or ``None`` if it failed or was over the cap."""
return self._skills_by_url.get(url)

def append_exception(self, exception: SkillInitializationException) -> None:
with self._lock:
self._exceptions.append(exception)
19 changes: 19 additions & 0 deletions src/quickapp/skill_invocation/_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class SkillInvocationSettings(BaseSettings):
"""Operator-level limits for skills the user invokes from a message."""

model_config = SettingsConfigDict()

max_skills: int = Field(
default=10,
gt=0,
description=(
"Maximum number of distinct picked skills resolved and listed per request "
"across the conversation, newest first. Each one adds a <skill> block to the "
"system prompt and one Core fetch on every turn."
),
alias="SKILL_INVOCATION_MAX_SKILLS",
)
Loading
Loading