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
9 changes: 9 additions & 0 deletions docs/en/docs/how-to/trace-with-phoenix.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@ Open <http://localhost:6006>, select the `default` project, and open the most re
| `invoke_agent memory_extraction` | One PowerContext generation task. The name identifies the purpose, not the model. |
| `chat <model>` | One request to the model provider, with token usage and latency. |

Memory read operations add the following internal stage spans beneath their application operation:

| Span | Meaning |
| --- | --- |
| `memory.search` | Memory lookup for `search_memory` or `prepare_context`; embedding and reranking spans, when present, are nested beneath it. |
| `memory.rerank` | One actual reranker call; model-backed reranking nests `invoke_agent memory_rerank` beneath it. |
| `experience.search` | Experience recall during `prepare_context`; emitted even when recall is not configured. |
| `context.build` | The synchronous step that selects and renders the final prepared context from recalled candidates. |

The other PowerContext generation tasks appear under the same convention: `experience_incubation`,
`experience_generation`, `skill_generation`, `handoff_generation`, and `memory_rerank`. When an embedding model is
configured, embedding calls appear as `embeddings <model>` spans under the operation that triggered them.
Expand Down
4 changes: 4 additions & 0 deletions docs/en/rfcs/0046_observability_foundations.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,13 @@ PowerContext distinguishes these units:
| --- | --- |
| Transport request | One external HTTP or MCP protocol request |
| Application operation | One stable PowerContext operation, such as `search_memory` |
| Runtime stage | One bounded internal step within an application operation, such as `memory.search` |
| Background activation | One manual or scheduled Source processing activation |
| Dependency call | One outbound call to another service or provider |

Runtime stage spans use `stage` as their `powercontext.operation.unit` value. They expose internal latency without
creating another application operation.

A direct HTTP call produces one external request and one application operation. An MCP tool call also produces one
external request and one application operation. Its internal HTTP bridge does not count as a second external request.

Expand Down
9 changes: 9 additions & 0 deletions docs/zh/docs/how-to/trace-with-phoenix.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@ Memory extraction 发生在 flush 阶段,而不是捕获阶段。
| `invoke_agent memory_extraction` | 一次 PowerContext generation 任务。名字标识用途,不是模型名。 |
| `chat <model>` | 一次发往模型 provider 的请求,包含 token 用量和耗时。 |

Memory 读取操作还会在 application operation 之下添加以下内部 stage span:

| Span | 含义 |
| --- | --- |
| `memory.search` | `search_memory` 或 `prepare_context` 中的 Memory 查询;存在 embedding 或 reranking span 时,它们嵌套在其下。 |
| `memory.rerank` | 一次实际 reranker 调用;使用模型的 reranking 会在其下嵌套 `invoke_agent memory_rerank`。 |
| `experience.search` | `prepare_context` 中的 Experience recall;未配置 recall 时也会产生。 |
| `context.build` | 根据召回候选同步选择并渲染最终 prepared context 的步骤。 |

其他 generation 任务遵循同样的命名约定:`experience_incubation`、`experience_generation`、`skill_generation`、
`handoff_generation` 和 `memory_rerank`。配置了 embedding model 时,embedding 调用会作为 `embeddings <model>`
span 挂在触发它的操作之下。
Expand Down
4 changes: 4 additions & 0 deletions docs/zh/rfcs/0046_observability_foundations.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,13 @@ PowerContext 区分以下工作单元:
| --- | --- |
| Transport request | 一次外部 HTTP 或 MCP protocol request |
| Application operation | 一项稳定的 PowerContext operation,例如 `search_memory` |
| Runtime stage | application operation 中一个有界的内部步骤,例如 `memory.search` |
| Background activation | 一次手动或定时 Source processing activation |
| Dependency call | 一次对其他 service 或 provider 的 outbound call |

Runtime stage span 的 `powercontext.operation.unit` 值为 `stage`。它们用于展示内部耗时,但不会产生新的
application operation。

直接 HTTP call 会产生一次 external request 和一次 application operation。MCP tool call 同样产生一次
external request 和一次 application operation。其内部 HTTP bridge 不计为第二次 external request。

Expand Down
11 changes: 10 additions & 1 deletion src/powercontext/builtin/inference/pydantic_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

import asyncio
from collections.abc import Sequence
from typing import Generic, TypeVar, cast
from copy import copy
from typing import Generic, Self, TypeVar, cast

from pydantic import BaseModel, Field

Expand Down Expand Up @@ -172,6 +173,14 @@ def __init__(
self._batch_size = batch_size
self._limits = InferenceLimits() if limits is None else limits

def _without_instrumentation(self) -> Self:
"""Copy this adapter for readiness without changing operational tracing."""

adapter = copy(self)
adapter._embedder = copy(self._embedder)
adapter._embedder.instrument = False
return adapter

async def embed(self, texts: tuple[str, ...], /) -> EmbeddingResult:
"""Embed documents and validate order, count, dimension, and finite values."""

Expand Down
190 changes: 136 additions & 54 deletions src/powercontext/builtin/runtime/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

import asyncio
import logging
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
from contextlib import AbstractContextManager, asynccontextmanager, nullcontext
from datetime import UTC, datetime
from pathlib import Path
from time import perf_counter
Expand Down Expand Up @@ -93,7 +93,13 @@
SourceReceipt,
)
from powercontext.builtin.runtime.prepared_context import PreparedContextBuild, PreparedContextBuilder
from powercontext.builtin.runtime.protocols import BuiltinTriggers, PowerContextProvider
from powercontext.builtin.runtime.protocols import (
BuiltinTriggers,
PowerContextProvider,
RuntimeSpan,
RuntimeTracing,
TraceAttribute,
)
from powercontext.builtin.runtime.readiness import (
ReadinessCheckStatus,
RuntimeReadiness,
Expand All @@ -118,6 +124,13 @@

logger = logging.getLogger(__name__)

_MEMORY_SEARCH_STAGE = "memory.search"
_MEMORY_SEARCH_REQUESTED_MODE = "powercontext.memory.search.requested_mode"
_MEMORY_SEARCH_LIMIT = "powercontext.memory.search.limit"
_MEMORY_SEARCH_MEMORY_PRESENT = "powercontext.memory.search.memory_present"
_MEMORY_SEARCH_MODE = "powercontext.memory.search.mode"
_MEMORY_SEARCH_RESULT_COUNT = "powercontext.memory.search.result_count"

ScopeIds = Callable[[], Awaitable[tuple[str, ...]]]
ReviewServiceFactory = Callable[[str], ReviewService]
GenerationServiceFactory = Callable[[str], ReviewedGenerationService]
Expand Down Expand Up @@ -266,32 +279,72 @@ async def prepare(self, request: PrepareContextRequest, /) -> PreparedContext:
self._runtime._context(self.scope_id, embedding_purpose=ModelUsagePurpose.MEMORY_RECALL) as context,
self._runtime._lock(self.scope_id),
):
service = context.artifacts.memory
current = await _head_or_none(service, context.artifacts.memory_artifact_id)
memory_hits = ()
if current is not None:
result = await service.search(
request.query,
memories=(current,),
limit=builder.memory_candidate_limit,
mode="auto",
with self._runtime._stage(
_MEMORY_SEARCH_STAGE,
attributes={
_MEMORY_SEARCH_REQUESTED_MODE: "auto",
_MEMORY_SEARCH_LIMIT: builder.memory_candidate_limit,
},
) as span:
service = context.artifacts.memory
current = await _head_or_none(service, context.artifacts.memory_artifact_id)
memory_hits = ()
if current is not None:
result = await service.search(
request.query,
memories=(current,),
limit=builder.memory_candidate_limit,
mode="auto",
)
memory_hits = result.hits
if span is not None:
attributes: dict[str, TraceAttribute] = {
_MEMORY_SEARCH_MEMORY_PRESENT: current is not None,
_MEMORY_SEARCH_RESULT_COUNT: len(memory_hits),
}
if current is not None:
attributes[_MEMORY_SEARCH_MODE] = result.mode
span.set_attributes(attributes)

experience_recall = self._runtime._experience_recall
with self._runtime._stage(
"experience.search",
attributes={
"powercontext.experience.search.configured": experience_recall is not None,
"powercontext.experience.search.limit": builder.experience_candidate_limit,
},
) as span:
experience_hits = (
()
if experience_recall is None
else await experience_recall(
self.scope_id,
request.query,
builder.experience_candidate_limit,
)
)
memory_hits = result.hits
experience_hits = (
()
if self._runtime._experience_recall is None
else await self._runtime._experience_recall(
self.scope_id,
request.query,
builder.experience_candidate_limit,
if span is not None:
span.set_attributes({"powercontext.experience.search.result_count": len(experience_hits)})

with self._runtime._stage(
"context.build",
attributes={
"powercontext.context.build.memory_candidate_count": len(memory_hits),
"powercontext.context.build.experience_candidate_count": len(experience_hits),
},
) as span:
build = builder.build_result(
request=request,
memory_ref=None if current is None else current.as_ref(),
hits=memory_hits,
experience_hits=experience_hits,
)
)
build = builder.build_result(
request=request,
memory_ref=None if current is None else current.as_ref(),
hits=memory_hits,
experience_hits=experience_hits,
)
if span is not None:
span.set_attributes({
"powercontext.context.build.selected_count": len(build.origins),
"powercontext.context.build.status": build.context.status,
"powercontext.context.build.content_bytes": build.context.content_bytes,
})
if self._runtime._recall_token_estimator is not None:
try:
measurement = await self._runtime._recall_token_estimator(self.scope_id, build)
Expand Down Expand Up @@ -658,34 +711,51 @@ async def search(self, request: SearchMemoryRequest, /) -> MemorySearchPage:
generation_purpose=ModelUsagePurpose.MEMORY_RECALL,
embedding_purpose=ModelUsagePurpose.MEMORY_RECALL,
) as context:
service = context.artifacts.memory
attempt = 1
while True:
current = await _head_or_none(service, context.artifacts.memory_artifact_id)
if current is None:
return MemorySearchPage(memory_ref=None, mode=None)
try:
result = await service.search(
request.query,
memories=(current,),
limit=request.limit,
mode=request.mode,
with self._runtime._stage(
_MEMORY_SEARCH_STAGE,
attributes={
_MEMORY_SEARCH_REQUESTED_MODE: request.mode,
_MEMORY_SEARCH_LIMIT: request.limit,
},
) as span:
service = context.artifacts.memory
attempt = 1
while True:
current = await _head_or_none(service, context.artifacts.memory_artifact_id)
if current is None:
if span is not None:
span.set_attributes({
_MEMORY_SEARCH_MEMORY_PRESENT: False,
_MEMORY_SEARCH_RESULT_COUNT: 0,
})
return MemorySearchPage(memory_ref=None, mode=None)
try:
result = await service.search(
request.query,
memories=(current,),
limit=request.limit,
mode=request.mode,
)
except (CapabilityNotSupportedError, InvalidMemoryCitationError) as error:
latest = await _head_or_none(service, context.artifacts.memory_artifact_id)
if not _is_stale_memory_search(error) or latest is None or latest.as_ref() == current.as_ref():
raise
if attempt == _MEMORY_SEARCH_ATTEMPTS:
raise RevisionConflictError(current, latest) from error
attempt += 1
continue
if span is not None:
span.set_attributes({
_MEMORY_SEARCH_MEMORY_PRESENT: True,
_MEMORY_SEARCH_MODE: result.mode,
_MEMORY_SEARCH_RESULT_COUNT: len(result.hits),
})
return MemorySearchPage(
memory_ref=current.as_ref(),
mode=result.mode,
hits=result.hits,
rerank=result.rerank,
)
except (CapabilityNotSupportedError, InvalidMemoryCitationError) as error:
latest = await _head_or_none(service, context.artifacts.memory_artifact_id)
if not _is_stale_memory_search(error) or latest is None or latest.as_ref() == current.as_ref():
raise
if attempt == _MEMORY_SEARCH_ATTEMPTS:
raise RevisionConflictError(current, latest) from error
attempt += 1
continue

return MemorySearchPage(
memory_ref=current.as_ref(),
mode=result.mode,
hits=result.hits,
rerank=result.rerank,
)

async def list(self, *, include_inactive: bool = False) -> MemoryEntriesPage:
async with self._runtime._context(self.scope_id) as context:
Expand Down Expand Up @@ -931,6 +1001,7 @@ def __init__(
recall_token_estimator: RecallTokenEstimator | None = None,
readiness: RuntimeReadinessChecks | None = None,
clock: Clock | None = None,
tracing: RuntimeTracing | None = None,
) -> None:
if source_window_limit < 1:
raise _RuntimeConfigurationError("source_window_limit")
Expand All @@ -946,6 +1017,7 @@ def __init__(
self._recall_token_estimator = recall_token_estimator
self._readiness = RuntimeReadinessChecks() if readiness is None else readiness
self._clock = _utc_now if clock is None else clock
self._tracing = tracing
self.source_window_limit = source_window_limit
self._locks: dict[str, asyncio.Lock] = {}
self._processor_lock = asyncio.Lock()
Expand Down Expand Up @@ -1131,6 +1203,16 @@ async def _context(
def _lock(self, scope_id: str) -> asyncio.Lock:
return self._locks.setdefault(validate_scope_id(scope_id), asyncio.Lock())

def _stage(
self,
name: str,
*,
attributes: Mapping[str, TraceAttribute],
) -> AbstractContextManager[RuntimeSpan | None]:
if self._tracing is None:
return nullcontext(None)
return self._tracing.stage(name, attributes=attributes)

def _review(self, scope_id: str) -> ReviewService:
if self._review_service is None:
raise _RuntimeStateError("review")
Expand Down
Loading