feat(agent-runner): expose effective prompt and transcript history

This commit is contained in:
huanghuoguoguo
2026-06-04 00:21:30 +08:00
parent 08c51118c5
commit 7053acfb1b
14 changed files with 363 additions and 85 deletions

View File

@@ -64,6 +64,20 @@ def uses_host_knowledge_bases(descriptor: AgentRunnerDescriptor | None) -> bool:
)
def supports_skill_authoring(descriptor: AgentRunnerDescriptor | None) -> bool:
"""Return whether the runner wants Host skill-authoring tools."""
if descriptor is None:
return False
return bool(descriptor.capabilities.get('skill_authoring', False))
def supports_skill_injection(descriptor: AgentRunnerDescriptor | None) -> bool:
"""Return whether the runner wants the Host skill index in the effective prompt."""
if descriptor is None:
return False
return bool(descriptor.capabilities.get('skill_injection', False))
def extract_prompt_config(
descriptor: AgentRunnerDescriptor | None,
runner_config: dict[str, typing.Any],

View File

@@ -137,6 +137,8 @@ class AgentRunOrchestrator:
# Merge params into adapter.extra
if 'params' in adapter_context:
context['adapter']['extra']['params'] = adapter_context['params']
if adapter_context.get('prompt_get'):
context['context']['available_apis']['prompt_get'] = True
# Build state context for State API handlers
state_context = build_state_context(event, binding, descriptor)

View File

@@ -148,6 +148,7 @@ class QueryEntryAdapter:
return {
'params': cls.build_params(query),
'query_id': getattr(query, 'query_id', None),
'prompt_get': cls._has_effective_prompt(query),
}
@classmethod
@@ -185,6 +186,12 @@ class QueryEntryAdapter:
)
return False
@classmethod
def _has_effective_prompt(cls, query: pipeline_query.Query) -> bool:
prompt = getattr(query, 'prompt', None)
messages = getattr(prompt, 'messages', None) if prompt is not None else None
return isinstance(messages, list)
# Private helper methods
@classmethod
@@ -374,24 +381,18 @@ class QueryEntryAdapter:
content = getattr(user_message, 'content', None)
if isinstance(content, list):
for elem in content:
# Handle both real objects and mocks
elem_dict = None
if hasattr(elem, 'model_dump'):
contents.append(elem.model_dump(mode='json'))
elem_dict = elem.model_dump(mode='json')
elif isinstance(elem, dict):
contents.append(elem)
else:
# For mocks, extract type and text attributes
elem_type = getattr(elem, 'type', None)
if elem_type == 'text':
elem_text = getattr(elem, 'text', None)
contents.append({'type': 'text', 'text': elem_text})
if elem_text:
text_parts.append(elem_text)
elem_dict = elem
if not isinstance(elem_dict, dict):
continue
# Extract text for the text field
if hasattr(elem, 'type') and getattr(elem, 'type', None) == 'text':
elem_text = getattr(elem, 'text', None)
contents.append(elem_dict)
if elem_dict.get('type') == 'text':
elem_text = elem_dict.get('text')
if elem_text:
text_parts.append(elem_text)
elif content is not None:
@@ -466,36 +467,37 @@ class QueryEntryAdapter:
message_chain = getattr(query, 'message_chain', None)
if message_chain:
try:
for component in message_chain:
artifact_id = str(uuid.uuid4()) # Generate unique ID
if isinstance(component, platform_message.Image):
attachments.append({
'artifact_id': artifact_id,
'artifact_type': 'image',
'source': 'message_chain',
'id': component.image_id or None,
'url': component.url or None,
})
elif isinstance(component, platform_message.File):
attachments.append({
'artifact_id': artifact_id,
'artifact_type': 'file',
'source': 'message_chain',
'id': component.id or None,
'name': component.name or None,
})
elif isinstance(component, platform_message.Voice):
attachments.append({
'artifact_id': artifact_id,
'artifact_type': 'voice',
'source': 'message_chain',
'id': component.voice_id or None,
'url': component.url or None,
})
message_components = iter(message_chain)
except TypeError:
# message_chain is not iterable (e.g., a Mock object)
pass
message_components = iter(())
for component in message_components:
artifact_id = str(uuid.uuid4()) # Generate unique ID
if isinstance(component, platform_message.Image):
attachments.append({
'artifact_id': artifact_id,
'artifact_type': 'image',
'source': 'message_chain',
'id': component.image_id or None,
'url': component.url or None,
})
elif isinstance(component, platform_message.File):
attachments.append({
'artifact_id': artifact_id,
'artifact_type': 'file',
'source': 'message_chain',
'id': component.id or None,
'name': component.name or None,
})
elif isinstance(component, platform_message.Voice):
attachments.append({
'artifact_id': artifact_id,
'artifact_type': 'voice',
'source': 'message_chain',
'id': component.voice_id or None,
'url': component.url or None,
})
return attachments

View File

@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import sessionmaker
from ...entity.persistence.transcript import Transcript
from langbot_plugin.api.entities.builtin.provider import message as provider_message
class TranscriptStore:
@@ -225,6 +226,30 @@ class TranscriptStore:
return None
return str(row)
async def get_legacy_provider_messages(
self,
conversation_id: str,
limit: int = HARD_LIMIT,
) -> list[provider_message.Message]:
"""Project Transcript rows into the legacy provider Message view.
AgentRunner history is canonical in Transcript. This view exists for
legacy Pipeline readers such as PromptPreProcessing that still expect
query.messages.
"""
items, _, _, _ = await self.page_transcript(
conversation_id=conversation_id,
limit=limit,
direction="backward",
)
messages: list[provider_message.Message] = []
for item in reversed(items):
message = self._transcript_item_to_provider_message(item)
if message is not None:
messages.append(message)
return messages
async def has_history_before(
self,
conversation_id: str,
@@ -288,3 +313,29 @@ class TranscriptStore:
result['artifact_refs'] = []
return result
def _transcript_item_to_provider_message(
self,
item: dict[str, typing.Any],
) -> provider_message.Message | None:
"""Convert one Transcript API item into a provider Message."""
if item.get('item_type') != 'message':
return None
role = item.get('role')
if role not in {'user', 'assistant'}:
return None
content_json = item.get('content_json')
if isinstance(content_json, dict):
message_data = dict(content_json)
message_data['role'] = role
try:
return provider_message.Message.model_validate(message_data)
except Exception:
pass
content = item.get('content')
if content is None:
return None
return provider_message.Message(role=role, content=content)