mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-08 14:56:03 +00:00
feat(agent-runner): expose effective prompt and transcript history
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -19,8 +19,6 @@ DEFAULT_PROMPT_CONFIG = [
|
||||
{'role': 'system', 'content': 'You are a helpful assistant.'},
|
||||
]
|
||||
|
||||
LOCAL_AGENT_RUNNER_ID = 'plugin:langbot/local-agent/default'
|
||||
|
||||
|
||||
@stage.stage_class('PreProcessor')
|
||||
class PreProcessor(stage.PipelineStage):
|
||||
@@ -107,6 +105,48 @@ class PreProcessor(stage.PipelineStage):
|
||||
if isinstance(msg.content, list):
|
||||
msg.content = [elem for elem in msg.content if elem.type != 'image_url']
|
||||
|
||||
def _has_declared_db_engine(self) -> bool:
|
||||
persistence_mgr = getattr(self.ap, 'persistence_mgr', None)
|
||||
if persistence_mgr is None:
|
||||
return False
|
||||
if 'get_db_engine' in getattr(persistence_mgr, '__dict__', {}):
|
||||
return True
|
||||
return hasattr(type(persistence_mgr), 'get_db_engine')
|
||||
|
||||
async def _load_agent_runner_history_messages(
|
||||
self,
|
||||
runner_id: str | None,
|
||||
conversation_uuid: str | None,
|
||||
) -> list[provider_message.Message] | None:
|
||||
if not runner_id or not conversation_uuid or not self._has_declared_db_engine():
|
||||
return None
|
||||
|
||||
try:
|
||||
from ...agent.runner.transcript_store import TranscriptStore
|
||||
|
||||
store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
|
||||
messages = await store.get_legacy_provider_messages(str(conversation_uuid))
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(
|
||||
f'Unable to load Transcript history view for conversation {conversation_uuid}: {e}'
|
||||
)
|
||||
return None
|
||||
|
||||
return messages or None
|
||||
|
||||
async def _resolve_history_messages(
|
||||
self,
|
||||
runner_id: str | None,
|
||||
conversation: typing.Any,
|
||||
) -> list[provider_message.Message]:
|
||||
transcript_messages = await self._load_agent_runner_history_messages(
|
||||
runner_id,
|
||||
getattr(conversation, 'uuid', None),
|
||||
)
|
||||
if transcript_messages is not None:
|
||||
return transcript_messages
|
||||
return conversation.messages.copy()
|
||||
|
||||
async def process(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
@@ -127,8 +167,11 @@ class PreProcessor(stage.PipelineStage):
|
||||
|
||||
uses_host_models = config_schema.uses_host_models(descriptor)
|
||||
uses_host_tools = config_schema.uses_host_tools(descriptor)
|
||||
is_local_agent = runner_id == LOCAL_AGENT_RUNNER_ID
|
||||
include_skill_authoring = is_local_agent and getattr(self.ap, 'skill_service', None) is not None
|
||||
include_skill_authoring = (
|
||||
config_schema.supports_skill_authoring(descriptor)
|
||||
and getattr(self.ap, 'skill_service', None) is not None
|
||||
)
|
||||
inject_skill_context = config_schema.supports_skill_injection(descriptor)
|
||||
llm_model = None
|
||||
if uses_host_models:
|
||||
primary_uuid, fallback_uuids = config_schema.extract_model_selection(descriptor, runner_config)
|
||||
@@ -171,7 +214,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
# 设置query
|
||||
query.session = session
|
||||
query.prompt = conversation.prompt.copy()
|
||||
query.messages = conversation.messages.copy()
|
||||
query.messages = await self._resolve_history_messages(runner_id, conversation)
|
||||
|
||||
if uses_host_models:
|
||||
query.use_funcs = []
|
||||
@@ -307,7 +350,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
query.prompt.messages = event_ctx.event.default_prompt
|
||||
query.messages = event_ctx.event.prompt
|
||||
|
||||
# =========== Skill awareness for the local-agent runner ===========
|
||||
# =========== Skill awareness for capable runners ===========
|
||||
# The actual activation goes through the ``activate`` Tool Call so the
|
||||
# LLM doesn't see full SKILL.md instructions until it commits to a
|
||||
# skill (Claude Code's progressive disclosure). But the LLM still has
|
||||
@@ -319,7 +362,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
# only) into the system prompt. The contributor's original PR
|
||||
# relied on this injection; without it the LLM never discovers
|
||||
# the skills are there and just calls native tools instead.
|
||||
if is_local_agent and self.ap.skill_mgr:
|
||||
if inject_skill_context and self.ap.skill_mgr:
|
||||
pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid)
|
||||
extensions_prefs = (pipeline_data or {}).get('extensions_preferences', {})
|
||||
enable_all_skills = extensions_prefs.get('enable_all_skills', True)
|
||||
|
||||
@@ -146,10 +146,11 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
f'Conversation({query.query_id}) Streaming completed: {chunk_count} chunks, {text_length} chars'
|
||||
)
|
||||
|
||||
# Update conversation history
|
||||
conversation = await self._ensure_conversation_for_history(query)
|
||||
conversation.messages.append(query.user_message)
|
||||
conversation.messages.extend(query.resp_messages)
|
||||
# Keep a conversation object available for downstream legacy
|
||||
# readers, but do not mirror AgentRunner history into
|
||||
# conversation.messages. TranscriptStore is the canonical
|
||||
# history source for this path.
|
||||
await self._ensure_conversation_for_history(query)
|
||||
|
||||
except Exception as e:
|
||||
# Import orchestrator errors for specific handling
|
||||
|
||||
@@ -368,6 +368,25 @@ def _resolve_remove_think(data: dict[str, Any], query: Any | None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _dump_prompt_messages(query: Any) -> list[dict[str, Any]]:
|
||||
"""Serialize the current effective prompt from a cached Query."""
|
||||
prompt = getattr(query, 'prompt', None)
|
||||
messages = getattr(prompt, 'messages', None) if prompt is not None else None
|
||||
if not isinstance(messages, list):
|
||||
return []
|
||||
|
||||
dumped: list[dict[str, Any]] = []
|
||||
for message in messages:
|
||||
if hasattr(message, 'model_dump'):
|
||||
try:
|
||||
dumped.append(message.model_dump(mode='json'))
|
||||
except TypeError:
|
||||
dumped.append(message.model_dump())
|
||||
elif isinstance(message, dict):
|
||||
dumped.append(message)
|
||||
return dumped
|
||||
|
||||
|
||||
def _merge_model_extra_args(model: Any, call_extra_args: Any) -> dict[str, Any]:
|
||||
"""Merge persisted model extra_args with action-level overrides."""
|
||||
merged: dict[str, Any] = {}
|
||||
@@ -787,17 +806,21 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
|
||||
For AgentRunner calls: requires run_id and validates tool_name against session.resources.tools.
|
||||
For regular plugin calls: no run_id, unrestricted access (backward compatibility).
|
||||
|
||||
Note: SDK LangBotAPIProxy (legacy) sends 'tool_parameters' and expects 'tool_response'.
|
||||
SDK AgentRunAPIProxy sends 'parameters' and expects 'result'.
|
||||
Handler returns both for backward compatibility.
|
||||
"""
|
||||
tool_name = data['tool_name']
|
||||
# Support 'tool_parameters' (LangBotAPIProxy) and 'parameters' (AgentRunAPIProxy)
|
||||
parameters = data.get('tool_parameters') or data.get('parameters', {})
|
||||
run_id = data.get('run_id') # Optional: present for AgentRunner calls
|
||||
caller_plugin_identity = data.get('caller_plugin_identity') # Optional: for cross-plugin validation
|
||||
session = None
|
||||
is_agent_runner_call = bool(run_id)
|
||||
|
||||
if is_agent_runner_call:
|
||||
if 'parameters' not in data:
|
||||
return handler.ActionResponse.error(
|
||||
message='parameters is required for AgentRunner tool calls',
|
||||
)
|
||||
parameters = data.get('parameters') or {}
|
||||
else:
|
||||
parameters = data.get('tool_parameters') or {}
|
||||
|
||||
# Permission validation for AgentRunner calls
|
||||
if run_id:
|
||||
@@ -817,14 +840,9 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
parameters=parameters,
|
||||
query=query,
|
||||
)
|
||||
# Return both 'tool_response' (LangBotAPIProxy) and 'result' (AgentRunAPIProxy)
|
||||
# LangBotAPIProxy expects 'tool_response', AgentRunAPIProxy expects 'result'
|
||||
return handler.ActionResponse.success(
|
||||
data={
|
||||
'tool_response': result,
|
||||
'result': result, # backward compatibility
|
||||
},
|
||||
)
|
||||
if is_agent_runner_call:
|
||||
return handler.ActionResponse.success(data={'result': result})
|
||||
return handler.ActionResponse.success(data={'tool_response': result})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return handler.ActionResponse.error(
|
||||
@@ -1430,6 +1448,32 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
|
||||
# ================= Agent History/Event APIs =================
|
||||
|
||||
@self.action(PluginToRuntimeAction.PROMPT_GET)
|
||||
async def prompt_get(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
"""Return the post-preprocessing effective prompt for a query-backed run."""
|
||||
run_id = data.get('run_id')
|
||||
caller_plugin_identity = data.get('caller_plugin_identity')
|
||||
|
||||
if not run_id:
|
||||
return handler.ActionResponse.error(message='run_id is required')
|
||||
|
||||
session, error = await _validate_agent_run_session(
|
||||
run_id,
|
||||
caller_plugin_identity,
|
||||
self.ap,
|
||||
'Prompt get',
|
||||
)
|
||||
if error:
|
||||
return error
|
||||
|
||||
query = _resolve_action_query(data, session, self.ap)
|
||||
if query is None:
|
||||
return handler.ActionResponse.error(
|
||||
message='Prompt get is only available for query-backed agent runs',
|
||||
)
|
||||
|
||||
return handler.ActionResponse.success(data={'prompt': _dump_prompt_messages(query)})
|
||||
|
||||
@self.action(PluginToRuntimeAction.HISTORY_PAGE)
|
||||
async def history_page(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
"""Page through transcript history for a conversation.
|
||||
|
||||
Reference in New Issue
Block a user