Files
LangBot/plugins/Runner/acp-agent-runner/pkg/prompt.py
T

348 lines
12 KiB
Python

"""ACP prompt content block conversion helpers."""
from __future__ import annotations
import base64
import binascii
import mimetypes
import typing
import urllib.parse
def _attachment_get(attachment: typing.Any, key: str, default: typing.Any = None) -> typing.Any:
if isinstance(attachment, dict):
return attachment.get(key, default)
return getattr(attachment, key, default)
def _content_get(content: typing.Any, key: str, default: typing.Any = None) -> typing.Any:
if isinstance(content, dict):
return content.get(key, default)
return getattr(content, key, default)
def _image_url_from_content(content: typing.Any) -> str:
image_url = _content_get(content, "image_url")
if isinstance(image_url, dict):
return str(image_url.get("url") or "").strip()
return str(getattr(image_url, "url", image_url) or "").strip()
def _base64_payload_and_mime(
value: typing.Any,
default_mime_type: str,
) -> tuple[str | None, str]:
if not isinstance(value, str) or not value.strip():
return None, default_mime_type
payload = value.strip()
mime_type = default_mime_type
if payload.startswith("data:") and "," in payload:
header, payload = payload.split(",", 1)
if ";base64" not in header:
return None, mime_type
parsed_mime_type = header[5:].split(";", 1)[0].strip()
if parsed_mime_type:
mime_type = parsed_mime_type
try:
base64.b64decode(payload, validate=True)
except (binascii.Error, ValueError):
return None, mime_type
return payload, mime_type
def _content_type_from_name(name: typing.Any, default: str) -> str:
if isinstance(name, str) and name:
guessed, _ = mimetypes.guess_type(name)
if guessed:
return guessed
return default
def _attachment_name(attachment: typing.Any, default: str) -> str:
for key in ("name", "file_name", "platform_attachment_id", "id"):
value = _attachment_get(attachment, key)
if value:
return str(value)
return default
def _resource_uri(attachment: typing.Any, name: str) -> str:
url = _attachment_get(attachment, "url")
if url:
return str(url)
path = _attachment_get(attachment, "path")
if path:
return str(path)
return f"langbot-input://{urllib.parse.quote(str(name), safe='')}"
def _resource_link_block(
attachment: typing.Any,
*,
default_name: str,
default_mime_type: str,
) -> dict[str, typing.Any] | None:
uri = str(_attachment_get(attachment, "url") or _attachment_get(attachment, "path") or "").strip()
if not uri:
return None
name = _attachment_name(attachment, default_name)
mime_type = str(
_attachment_get(attachment, "mime_type")
or _attachment_get(attachment, "content_type")
or _content_type_from_name(name, default_mime_type)
)
block: dict[str, typing.Any] = {
"type": "resource_link",
"uri": uri,
"name": name,
"mimeType": mime_type,
}
size = _attachment_get(attachment, "size") or _attachment_get(attachment, "size_bytes")
if isinstance(size, int):
block["size"] = size
return block
def attachments_from_contents(contents: list[typing.Any]) -> list[dict[str, typing.Any]]:
attachments: list[dict[str, typing.Any]] = []
for item in contents or []:
item_type = _content_get(item, "type")
if item_type == "image_base64":
content = _content_get(item, "image_base64")
_, mime_type = _base64_payload_and_mime(content, "image/jpeg")
attachments.append(
{
"type": "image",
"name": "image",
"content": content,
"mime_type": mime_type,
"source": "content",
}
)
elif item_type == "image_url":
url = _image_url_from_content(item)
if url:
attachments.append(
{
"type": "image",
"name": "image",
"url": url,
"mime_type": _content_type_from_name(url, "image/*"),
"source": "content",
}
)
elif item_type == "file_base64":
content = _content_get(item, "file_base64")
name = _content_get(item, "file_name") or "file"
_, mime_type = _base64_payload_and_mime(
content,
_content_type_from_name(name, "application/octet-stream"),
)
attachments.append(
{
"type": "file",
"name": name,
"content": content,
"mime_type": mime_type,
"source": "content",
}
)
elif item_type == "file_url":
url = str(_content_get(item, "file_url") or "").strip()
if url:
name = _content_get(item, "file_name") or "file"
attachments.append(
{
"type": "file",
"name": name,
"url": url,
"mime_type": _content_type_from_name(name, "application/octet-stream"),
"source": "content",
}
)
return attachments
def prompt_capabilities(initialize_result: dict[str, typing.Any]) -> dict[str, bool]:
agent_capabilities = initialize_result.get("agentCapabilities")
if not isinstance(agent_capabilities, dict):
agent_capabilities = {}
prompt_caps = agent_capabilities.get("promptCapabilities")
if not isinstance(prompt_caps, dict):
prompt_caps = {}
return {
"image": bool(prompt_caps.get("image")),
"audio": bool(prompt_caps.get("audio")),
"embedded_context": bool(prompt_caps.get("embeddedContext") or prompt_caps.get("embedded_context")),
}
def _resource_block_from_attachment(
attachment: typing.Any,
*,
default_name: str,
default_mime_type: str,
) -> dict[str, typing.Any] | None:
content = _attachment_get(attachment, "content")
payload, mime_type = _base64_payload_and_mime(content, default_mime_type)
if not payload:
return None
name = _attachment_name(attachment, default_name)
mime_type = str(
_attachment_get(attachment, "mime_type")
or _attachment_get(attachment, "content_type")
or mime_type
or _content_type_from_name(name, default_mime_type)
)
resource: dict[str, typing.Any] = {
"uri": _resource_uri(attachment, name),
"mimeType": mime_type,
}
if mime_type.startswith("text/"):
try:
resource["text"] = base64.b64decode(payload, validate=True).decode("utf-8")
except (UnicodeDecodeError, binascii.Error, ValueError):
resource["blob"] = payload
else:
resource["blob"] = payload
return {"type": "resource", "resource": resource}
def _image_block_from_attachment(
attachment: typing.Any,
*,
default_name: str,
) -> dict[str, typing.Any] | None:
name = _attachment_name(attachment, default_name)
mime_type = str(
_attachment_get(attachment, "mime_type")
or _attachment_get(attachment, "content_type")
or _content_type_from_name(name, "image/jpeg")
)
payload, parsed_mime_type = _base64_payload_and_mime(_attachment_get(attachment, "content"), mime_type)
if not payload:
return None
return {
"type": "image",
"mimeType": parsed_mime_type or mime_type,
"data": payload,
}
def _audio_block_from_attachment(attachment: typing.Any) -> dict[str, typing.Any] | None:
name = _attachment_name(attachment, "audio")
mime_type = str(
_attachment_get(attachment, "mime_type")
or _attachment_get(attachment, "content_type")
or _content_type_from_name(name, "audio/mpeg")
)
payload, parsed_mime_type = _base64_payload_and_mime(_attachment_get(attachment, "content"), mime_type)
if not payload:
return None
return {
"type": "audio",
"mimeType": parsed_mime_type or mime_type,
"data": payload,
}
def _append_multimodal_note(
blocks: list[dict[str, typing.Any]],
omitted: dict[str, int],
) -> None:
notes: list[str] = []
if omitted.get("image"):
notes.append(
f"{omitted['image']} image attachment(s) were not sent because the ACP runtime "
"did not advertise image prompt support and no URL link was available."
)
if omitted.get("audio"):
notes.append(
f"{omitted['audio']} audio attachment(s) were not sent because the ACP runtime "
"did not advertise audio prompt support and no URL link was available."
)
if omitted.get("file"):
notes.append(
f"{omitted['file']} file attachment(s) were not embedded because the ACP runtime "
"did not advertise embedded context support and no URL link was available."
)
if notes:
blocks.append({"type": "text", "text": "LangBot attachment note: " + " ".join(notes)})
def _input_attachments(input_data: typing.Any) -> list[typing.Any]:
attachments = list(_attachment_get(input_data, "attachments", []) or [])
if attachments:
return attachments
return attachments_from_contents(list(_attachment_get(input_data, "contents", []) or []))
def has_acp_prompt_input(prompt_text: str, input_data: typing.Any) -> bool:
return bool(prompt_text.strip() or _input_attachments(input_data))
def acp_prompt_blocks(
prompt_text: str,
input_data: typing.Any,
prompt_caps: dict[str, bool],
) -> list[dict[str, typing.Any]]:
blocks: list[dict[str, typing.Any]] = []
if prompt_text.strip():
blocks.append({"type": "text", "text": prompt_text})
omitted: dict[str, int] = {"image": 0, "audio": 0, "file": 0}
for attachment in _input_attachments(input_data):
attachment_type = str(_attachment_get(attachment, "type") or "file").lower()
if attachment_type == "image":
block = _image_block_from_attachment(attachment, default_name="image")
if block and prompt_caps.get("image"):
blocks.append(block)
continue
link = _resource_link_block(attachment, default_name="image", default_mime_type="image/*")
if link:
blocks.append(link)
elif block:
omitted["image"] += 1
continue
if attachment_type in {"voice", "audio"}:
block = _audio_block_from_attachment(attachment)
if block and prompt_caps.get("audio"):
blocks.append(block)
continue
link = _resource_link_block(attachment, default_name="audio", default_mime_type="audio/*")
if link:
blocks.append(link)
elif block:
omitted["audio"] += 1
continue
name = _attachment_name(attachment, "file")
mime_type = str(
_attachment_get(attachment, "mime_type")
or _attachment_get(attachment, "content_type")
or _content_type_from_name(name, "application/octet-stream")
)
block = _resource_block_from_attachment(
attachment,
default_name=name,
default_mime_type=mime_type,
)
if block and prompt_caps.get("embedded_context"):
blocks.append(block)
continue
link = _resource_link_block(attachment, default_name=name, default_mime_type=mime_type)
if link:
blocks.append(link)
elif block:
omitted["file"] += 1
_append_multimodal_note(blocks, omitted)
if not blocks:
blocks.append({"type": "text", "text": "User input contains no text or supported attachments."})
return blocks