diff --git a/.changes/+agent-query-sink.added.md b/.changes/+agent-query-sink.added.md new file mode 100644 index 00000000..5597f3d4 --- /dev/null +++ b/.changes/+agent-query-sink.added.md @@ -0,0 +1 @@ +Add the asynchronous Agent Query Sink, owner-local Agent read tools, and Sink type discovery. diff --git a/app/business/agent/AGENTS.md b/app/business/agent/AGENTS.md index 3908f812..cee9ab90 100644 --- a/app/business/agent/AGENTS.md +++ b/app/business/agent/AGENTS.md @@ -14,6 +14,9 @@ transport policy. shape is a static type-checking contract, not a second runtime schema. - Tool registration is decorator-owned. Exact persisted Tool IDs have set semantics and are bound once per new Thread; later registry changes do not rewrite existing Thread schemas or handlers. +- Tool handlers are owner-local controllers: they own Agent input/result projection and call ordinary domain services. + Do not move domain behavior into this subtree or create a central `agent_tools` business package. The durable boundary is + documented in `docs/30-unit-tdd/business-pipeline-and-authority.md`. - Cancellation owns no rollback, retry, shielding, or compensation. Completed Tool effects remain. - `OBSRV__AGENT_DEBUG` enables development events through existing logging. These are diagnostic records, not execution persistence or recovery authority. Preserve the actual ToolResult and Turn outcome when changing debug instrumentation. diff --git a/app/business/agent/__init__.py b/app/business/agent/__init__.py index 5c8e0af8..33fcb1d3 100644 --- a/app/business/agent/__init__.py +++ b/app/business/agent/__init__.py @@ -11,6 +11,7 @@ ToolExecutionError, ) from .main import AgentManager +from .bootstrap import register_core_agent_tools from .persistence import ( InMemoryThreadPersistenceBackend, ThreadID, @@ -22,6 +23,7 @@ __all__ = [ "AgentError", "AgentManager", + "register_core_agent_tools", "AgentNotFoundError", "AgentToolBindingError", "AgentTurnActiveError", diff --git a/app/business/agent/bootstrap.py b/app/business/agent/bootstrap.py new file mode 100644 index 00000000..d55bff50 --- /dev/null +++ b/app/business/agent/bootstrap.py @@ -0,0 +1,17 @@ +"""Explicit registration entrypoint for core-owned Agent Tool controllers.""" + + +def register_core_agent_tools() -> None: + """Import every core Tool owner, including Sink delivery controllers. + + Decorators register Tools when their modules load; Python's import cache makes + repeated bootstrap calls harmless. Entry points must call this explicitly, + rather than rely on routes or package re-exports importing the owners first. + """ + from app.business.graph_navigation_retrieval import tools as _graph_tools + from app.business.info_base import tools as _info_base_tools + from app.business.info_base.resolver import tools as _resolver_tools + from app.business.organization import tools as _organization_tools + from app.business.sink import agent_query as _agent_query + + del _graph_tools, _info_base_tools, _resolver_tools, _organization_tools, _agent_query diff --git a/app/business/agent/projection.py b/app/business/agent/projection.py new file mode 100644 index 00000000..658b2a5d --- /dev/null +++ b/app/business/agent/projection.py @@ -0,0 +1,45 @@ +"""JSON projection shared by domain-owned Agent Tool controllers.""" + +import dataclasses +import typing + +import pydantic + +from app.schemas.ai import JSONValue + + +_JSON_ADAPTER = pydantic.TypeAdapter(JSONValue) + + +def project_json(value: typing.Any) -> JSONValue: + """Project ordinary domain values without admitting binary Tool results.""" + if _contains_bytes(value): + raise TypeError("Binary values are unavailable through JSON Agent Tools") + if isinstance(value, pydantic.BaseModel): + projected = value.model_dump(mode="json") + elif dataclasses.is_dataclass(value) and not isinstance(value, type): + projected = dataclasses.asdict(value) + else: + projected = pydantic.TypeAdapter(typing.Any).dump_python( + typing.cast(typing.Any, value), + mode="json", + ) + return _JSON_ADAPTER.validate_python(projected) + + +def _contains_bytes(value: typing.Any) -> bool: + if isinstance(value, bytes): + return True + if isinstance(value, pydantic.BaseModel): + return any( + _contains_bytes(getattr(value, field)) for field in value.__class__.model_fields + ) + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return any( + _contains_bytes(getattr(value, field.name)) for field in dataclasses.fields(value) + ) + if isinstance(value, dict): + return any(_contains_bytes(item) for item in value.values()) + if isinstance(value, list | tuple | set | frozenset): + return any(_contains_bytes(item) for item in value) + return False diff --git a/app/business/graph_navigation_retrieval/tools.py b/app/business/graph_navigation_retrieval/tools.py new file mode 100644 index 00000000..7c0a2eb0 --- /dev/null +++ b/app/business/graph_navigation_retrieval/tools.py @@ -0,0 +1,148 @@ +"""Agent Tool controllers for graph-navigation retrieval.""" + +import typing + +import pydantic + +from app.business.agent import AgentManager, ToolExecutionError +from app.business.agent.projection import project_json +from app.schemas.ai import JSONValue +from app.schemas.graph_navigation_retrieval import ( + DEFAULT_MAX_EXPLORED_BLOCKS, + DEFAULT_MAX_EXPLORED_RELATIONS, + DEFAULT_MAX_HOPS, + DEFAULT_NEIGHBORHOOD_LIMIT, + MAX_MAX_EXPLORED_BLOCKS, + MAX_MAX_HOPS, + MAX_NEIGHBORHOOD_LIMIT, + GraphDirection, +) +from app.schemas.info_base.block import BlockID +from app.schemas.info_base.relation import RelationID + +from .main import GraphNavigationRetrievalManager + + +GET_ENTITY_NEIGHBORHOOD_TOOL = "get_entity_neighborhood" +FIND_PATH_TOOL = "find_path" +GET_CONNECTED_COMPONENTS_TOOL = "get_connected_components" + + +class BlockNeighborhoodInput(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + entity_type: typing.Literal["block"] + entity_id: BlockID + direction: GraphDirection = "both" + contents: tuple[str, ...] = () + limit: int = pydantic.Field( + default=DEFAULT_NEIGHBORHOOD_LIMIT, ge=1, le=MAX_NEIGHBORHOOD_LIMIT + ) + cursor: RelationID | None = None + + +class RelationNeighborhoodInput(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + entity_type: typing.Literal["relation"] + entity_id: RelationID + + +class EntityNeighborhoodInput( + pydantic.RootModel[ + typing.Annotated[ + BlockNeighborhoodInput | RelationNeighborhoodInput, + pydantic.Field(discriminator="entity_type"), + ] + ] +): + model_config = pydantic.ConfigDict(json_schema_extra={"type": "object"}) + + @classmethod + def model_json_schema(cls, *args, **kwargs) -> dict[str, typing.Any]: + schema = super().model_json_schema(*args, **kwargs) + properties = BlockNeighborhoodInput.model_json_schema(*args, **kwargs)["properties"] + properties["entity_type"] = {"type": "string", "enum": ["block", "relation"]} + schema["properties"] = properties + return schema + + +class FindPathInput(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + from_block_id: BlockID + to_block_id: BlockID + direction: GraphDirection = "both" + contents: tuple[str, ...] = () + max_hops: int = pydantic.Field(default=DEFAULT_MAX_HOPS, ge=0, le=MAX_MAX_HOPS) + max_explored_blocks: int = pydantic.Field( + default=DEFAULT_MAX_EXPLORED_BLOCKS, ge=1, le=MAX_MAX_EXPLORED_BLOCKS + ) + + +class ConnectedComponentsInput(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + seed_block_ids: tuple[BlockID, ...] + contents: tuple[str, ...] = pydantic.Field(min_length=1) + max_explored_blocks: int = pydantic.Field(default=DEFAULT_MAX_EXPLORED_BLOCKS, ge=1) + max_explored_relations: int = pydantic.Field(default=DEFAULT_MAX_EXPLORED_RELATIONS, ge=1) + + +@AgentManager.tool( + GET_ENTITY_NEIGHBORHOOD_TOOL, + description=( + "Read a Block's direct neighborhood or a Relation with its endpoints. " + "Null may indicate an incorrect entity type." + ), +) +async def get_entity_neighborhood(input: EntityNeighborhoodInput) -> JSONValue: + request = input.root + if request.entity_type == "block": + result = await GraphNavigationRetrievalManager.get_block_neighborhood( + request.entity_id, + direction=request.direction, + contents=request.contents, + limit=request.limit, + cursor=request.cursor, + ) + else: + result = await GraphNavigationRetrievalManager.get_relation_neighborhood( + request.entity_id + ) + return project_json(result) + + +@AgentManager.tool( + FIND_PATH_TOOL, + description="Find a bounded graph path; an exploration limit is not proof of absence.", +) +async def find_path(input: FindPathInput) -> JSONValue: + return project_json( + await GraphNavigationRetrievalManager.find_path( + input.from_block_id, + input.to_block_id, + direction=input.direction, + contents=input.contents, + max_hops=input.max_hops, + max_explored_blocks=input.max_explored_blocks, + ) + ) + + +@AgentManager.tool( + GET_CONNECTED_COMPONENTS_TOOL, + description=( + "Partition seeds by bounded undirected reachability through exact Relation contents." + ), +) +async def get_connected_components(input: ConnectedComponentsInput) -> JSONValue: + try: + result = await GraphNavigationRetrievalManager.get_connected_components( + **input.model_dump() + ) + except ValueError as error: + raise ToolExecutionError( + {"error": type(error).__name__, "message": str(error)} + ) from error + return project_json(result) diff --git a/app/business/info_base/main.py b/app/business/info_base/main.py index 94464c94..8f1fd428 100644 --- a/app/business/info_base/main.py +++ b/app/business/info_base/main.py @@ -1,4 +1,16 @@ -"""Info-base graph command coordination.""" +"""Info-base graph command coordination and cross-retrieval use cases.""" + +import asyncio +from dataclasses import dataclass +import typing + +from app.business.info_base.services import BlockService, get_entity_records +from app.business.lexical_retrieval import LexicalRetrievalManager +from app.business.semantic_retrieval import SemanticRetrievalManager +from app.schemas.lexical_retrieval import LexicalRetrievalResult +from app.schemas.semantic_retrieval import SemanticRetrievalResult, VectorRetrievalOptions +from app.schemas.info_base.block import BlockModel +from app.schemas.info_base.relation import RelationModel from app.schemas.info_base.main import ( GraphBlockForm, @@ -8,8 +20,60 @@ ) +@dataclass(frozen=True) +class RetrievalBranch: + result: LexicalRetrievalResult | SemanticRetrievalResult | None = None + error: BaseException | None = None + + class InfoBaseManager: - """Own graph-form normalization and graph insertion coordination.""" + """Own graph commands and use cases that span info-base read capabilities.""" + + @classmethod + async def retrieve( + cls, + query: str, + mode: typing.Literal["lexical", "semantic", "hybrid"] = "hybrid", + limit: int = 20, + ) -> dict[str, RetrievalBranch]: + """Run selected retrieval owners independently without merging their ranking.""" + operations: dict[str, typing.Awaitable] = {} + if mode in {"lexical", "hybrid"}: + operations["lexical"] = LexicalRetrievalManager.retrieve_local(query, limit) + if mode in {"semantic", "hybrid"}: + operations["semantic"] = SemanticRetrievalManager.retrieve_local( + query, options=VectorRetrievalOptions(limit=limit) + ) + outcomes = await asyncio.gather(*operations.values(), return_exceptions=True) + return { + name: RetrievalBranch( + error=outcome if isinstance(outcome, BaseException) else None, + result=None if isinstance(outcome, BaseException) else outcome, + ) + for name, outcome in zip(operations, outcomes, strict=True) + } + + @classmethod + async def get_entities( + cls, + entities: typing.Collection[tuple[typing.Literal["block", "relation"], int]], + *, + random_count: int = 1, + ) -> tuple[BlockModel | RelationModel | None, ...]: + """Read ordered persisted entities, or random Blocks when selection is empty.""" + if not entities: + return typing.cast( + tuple[BlockModel | RelationModel | None, ...], + await BlockService.get_random_many(random_count), + ) + blocks, relations = await get_entity_records( + tuple(identity for kind, identity in entities if kind == "block"), + tuple(identity for kind, identity in entities if kind == "relation"), + ) + return tuple( + blocks.get(identity) if kind == "block" else relations.get(identity) + for kind, identity in entities + ) @classmethod def normalize_graph( diff --git a/app/business/info_base/resolver/tools.py b/app/business/info_base/resolver/tools.py new file mode 100644 index 00000000..b409bd17 --- /dev/null +++ b/app/business/info_base/resolver/tools.py @@ -0,0 +1,268 @@ +"""Agent Tool controller for typed Resolver method discovery and invocation.""" + +import typing + +import pydantic + +from app.business.agent import AgentManager +from app.business.agent.projection import project_json +from app.business.info_base.services import BlockService +from app.schemas.ai import JSONValue +from app.schemas.info_base.block import BlockID, ResolverType + +from .main import ResolverManager + + +RESOLVER_TOOL = "resolver" + + +class ResolverMethodCall(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + block_id: BlockID + method: str + arguments: dict[str, JSONValue] = pydantic.Field(default_factory=dict) + + +class ResolverDescribeInput(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + action: typing.Literal["describe"] + resolver_types: tuple[ResolverType, ...] = () + block_ids: tuple[BlockID, ...] = () + calls: tuple[ResolverMethodCall, ...] = pydantic.Field(default=(), max_length=0) + + +class ResolverInvokeInput(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + action: typing.Literal["invoke"] + resolver_types: tuple[ResolverType, ...] = pydantic.Field(default=(), max_length=0) + block_ids: tuple[BlockID, ...] = pydantic.Field(default=(), max_length=0) + calls: tuple[ResolverMethodCall, ...] = pydantic.Field(min_length=1, max_length=20) + + +class ResolverMetaToolInput( + pydantic.RootModel[ + typing.Annotated[ + ResolverDescribeInput | ResolverInvokeInput, + pydantic.Field(discriminator="action"), + ] + ] +): + pass + + +def _resolver_input_model() -> type[pydantic.BaseModel]: + common = ResolverManager.get_common_method_contracts() + variants: list[type[pydantic.BaseModel]] = [] + contracts = list(common) + common_names = {item.name for item in common} + for resolver_type in ResolverManager.RESOLVER_CLS: + contracts.extend( + contract + for contract in ResolverManager.get_method_contracts(resolver_type) + if contract.name in common_names + ) + seen: set[tuple] = set() + for contract in contracts: + signature = ( + contract.name, + tuple( + (name, repr(field.annotation), repr(field.default), repr(field.metadata)) + for name, field in contract.input_model.model_fields.items() + ), + ) + if signature in seen: + continue + seen.add(signature) + variants.append( + pydantic.create_model( + f"{contract.name}_Call_{len(variants)}", + __config__=pydantic.ConfigDict(extra="forbid"), + block_id=(int, ...), + method=( + typing.cast(typing.Any, typing.Literal)[contract.name], + pydantic.Field(description=contract.description), + ), + arguments=( + contract.input_model, + ... + if any( + field.is_required() for field in contract.input_model.model_fields.values() + ) + else pydantic.Field(default_factory=contract.input_model), + ), + ) + ) + + variants.append( + pydantic.create_model( + "ExtraMethodCall", + __base__=ResolverMethodCall, + method=( + str, + pydantic.Field( + json_schema_extra={"not": {"enum": [contract.name for contract in common]}} + ), + ), + ) + ) + call_type = typing.cast(typing.Any, typing.Union)[tuple(variants)] + invoke = pydantic.create_model( + "BoundResolverInvokeInput", + __base__=ResolverInvokeInput, + calls=(tuple[call_type, ...], pydantic.Field(min_length=1, max_length=20)), + ) + provider_envelope = pydantic.create_model( + "ResolverEnvelope", + __base__=ResolverDescribeInput, + action=(typing.Literal["describe", "invoke"], ...), + calls=(tuple[call_type, ...], pydantic.Field(default=(), max_length=20)), + ) + method_contract = pydantic.RootModel[ + typing.Annotated[ResolverDescribeInput | invoke, pydantic.Field(discriminator="action")] + ] + + # Runtime validates the dispatch envelope, then the selected Resolver validates + # each call's arguments. One invalid method argument must not reject the batch. + # The richer method schema below guides the model without changing that boundary. + class BoundResolverInput(ResolverMetaToolInput): + @classmethod + def model_json_schema(cls, *args, **kwargs) -> dict[str, typing.Any]: + method_schema = method_contract.model_json_schema(*args, **kwargs) + # Some providers infer parameter types only from top-level properties. + # Expose the wider envelope there while retaining the union's oneOf, + # discriminator and $defs so describe/invoke keep their distinct contracts. + provider_schema = provider_envelope.model_json_schema(*args, **kwargs) + method_schema.update(type="object", properties=provider_schema["properties"]) + method_schema.setdefault("$defs", {}).update(provider_schema.get("$defs", {})) + return method_schema + + return BoundResolverInput + + +@AgentManager.tool( + RESOLVER_TOOL, + input_model_factory=_resolver_input_model, + description="Describe or invoke public typed read methods on exact Block Resolvers.", +) +async def resolver(input: ResolverMetaToolInput) -> JSONValue: + request = input.root + if request.action == "describe": + found = await BlockService.get_many(request.block_ids) + resolver_ids = set(request.resolver_types) + resolver_ids.update(block.resolver for block in found) + if not request.block_ids and not request.resolver_types: + resolver_ids.update(ResolverManager.RESOLVER_CLS) + return typing.cast( + JSONValue, + { + "results": [ + { + "resolver": resolver_id, + "methods": [ + { + "name": contract.name, + "description": contract.description, + "input_schema": contract.input_schema, + } + for contract in ResolverManager.get_method_contracts(resolver_id) + ], + } + for resolver_id in sorted(resolver_ids) + if resolver_id in ResolverManager.RESOLVER_CLS + ], + "missing_blocks": sorted(set(request.block_ids) - {block.id for block in found}), + "missing_resolvers": sorted( + resolver_id + for resolver_id in resolver_ids + if resolver_id not in ResolverManager.RESOLVER_CLS + ), + }, + ) + + results: list[JSONValue] = [] + for index, call in enumerate(request.calls): + block = await BlockService.get(call.block_id) + if block is None: + results.append( + { + "index": index, + "block_id": call.block_id, + "method": call.method, + "error": "not_found", + } + ) + continue + contract = ResolverManager.get_method_contract(block.resolver, call.method) + if block.resolver not in ResolverManager.RESOLVER_CLS: + results.append( + { + "index": index, + "block_id": call.block_id, + "method": call.method, + "error": "resolver_unavailable", + "message": f"Resolver {block.resolver!r} is not registered.", + } + ) + continue + if contract is None: + results.append( + { + "index": index, + "block_id": call.block_id, + "method": call.method, + "error": "method_unavailable", + "message": "Method does not exist; use describe for available method contracts.", + "available_methods": [ + item.name for item in ResolverManager.get_method_contracts(block.resolver) + ], + } + ) + continue + try: + value = await ResolverManager.invoke_method( + block, + call.method, + call.arguments.model_dump() + if isinstance(call.arguments, pydantic.BaseModel) + else typing.cast(dict[str, typing.Any], call.arguments), + ) + projected = project_json(value) + except pydantic.ValidationError as error: + results.append( + typing.cast( + JSONValue, + { + "index": index, + "block_id": call.block_id, + "method": call.method, + "error": "invalid_arguments", + "fields": error.errors( + include_url=False, include_context=False, include_input=False + ), + "input_schema": contract.input_schema, + }, + ) + ) + except Exception as error: + results.append( + { + "index": index, + "block_id": call.block_id, + "method": call.method, + "error": type(error).__name__, + "message": str(error), + } + ) + else: + results.append( + { + "index": index, + "block_id": call.block_id, + "method": call.method, + "result": projected, + } + ) + return typing.cast(JSONValue, {"results": results}) diff --git a/app/business/info_base/tools.py b/app/business/info_base/tools.py new file mode 100644 index 00000000..a4696794 --- /dev/null +++ b/app/business/info_base/tools.py @@ -0,0 +1,106 @@ +"""Agent Tool controllers for info-base retrieval and entity reads.""" + +import typing + +import pydantic + +from app.business.agent import AgentManager +from app.business.agent.projection import project_json +from app.schemas.ai import JSONValue + +from .main import InfoBaseManager + + +RETRIEVE_TOOL = "retrieve" +GET_ENTITIES_TOOL = "get_entities" +RetrievalMode: typing.TypeAlias = typing.Literal["lexical", "semantic", "hybrid"] + + +class RetrieveInput(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + query: str + mode: RetrievalMode = "hybrid" + limit: int = pydantic.Field(default=20, ge=1, le=20) + + @pydantic.field_validator("query") + @classmethod + def non_empty_query(cls, value: str) -> str: + if not value.strip(): + raise ValueError("query must not be empty") + return value + + +class EntityReference(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + type: typing.Literal["block", "relation"] + id: int + + +class GetEntitiesInput(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + entities: tuple[EntityReference, ...] = pydantic.Field(default=(), max_length=20) + random_count: int = pydantic.Field(default=1, ge=1, le=20) + + @pydantic.model_validator(mode="after") + def validate_selection(self) -> typing.Self: + if self.entities and self.random_count != 1: + raise ValueError("random_count only applies when entities is empty") + return self + + +@AgentManager.tool( + RETRIEVE_TOOL, + description="Retrieve lexical, semantic, or separate hybrid results for one query.", +) +async def retrieve(input: RetrieveInput) -> JSONValue: + branches = await InfoBaseManager.retrieve(input.query, input.mode, input.limit) + payload: dict[str, JSONValue] = {} + for name, branch in branches.items(): + if branch.error is not None: + payload[name] = { + "error": type(branch.error).__name__, + "message": str(branch.error), + } + continue + result = branch.result + assert result is not None + if name == "lexical": + payload[name] = { + "matches": [ + { + "entity": {"entity_type": "block", "entity_id": match.block.id}, + **match.model_dump(mode="json", exclude={"block"}), + } + for match in typing.cast(typing.Any, result).matches + ] + } + else: + payload[name] = { + **result.model_dump(mode="json", exclude={"matches"}), + "matches": [ + { + "entity": {"entity_type": match.type, "entity_id": match.entity.id}, + "score": match.score, + } + for match in typing.cast(typing.Any, result).matches + ], + } + return payload + + +@AgentManager.tool( + GET_ENTITIES_TOOL, + description=( + "Read persisted Blocks or Relations without resolving content. " + "Null may indicate an incorrect entity type." + ), +) +async def get_entities(input: GetEntitiesInput) -> JSONValue: + result = await InfoBaseManager.get_entities( + tuple((reference.type, reference.id) for reference in input.entities), + random_count=input.random_count, + ) + return project_json(result) diff --git a/app/business/organization/__init__.py b/app/business/organization/__init__.py index 63170d90..2d7c161c 100644 --- a/app/business/organization/__init__.py +++ b/app/business/organization/__init__.py @@ -42,17 +42,11 @@ CREATE_SYNTHESIS_TOOL, DRAFT_GRAPH_TOOL, GET_DRAFT_GRAPH_SCHEMA_TOOL, - GET_ENTITIES_TOOL, - GET_ENTITY_NEIGHBORHOOD_TOOL, - FIND_PATH_TOOL, - GET_CONNECTED_COMPONENTS_TOOL, RECORD_DUPLICATE_ASSERTION_TOOL, RECORD_EVIDENCE_STANCE_TOOL, RECORD_ORGANIZATION_CANDIDATE_TOOL, RECORD_REFINEMENT_TOOL, RECORD_SUPERSESSION_TOOL, - RESOLVER_TOOL, - RETRIEVE_TOOL, SUBMIT_GRAPH_TOOL, ) @@ -68,10 +62,6 @@ "EvidenceStanceBehaviorResolver", "ExistingReferentAnchoringBehaviorResolver", "GET_DRAFT_GRAPH_SCHEMA_TOOL", - "GET_ENTITIES_TOOL", - "GET_ENTITY_NEIGHBORHOOD_TOOL", - "FIND_PATH_TOOL", - "GET_CONNECTED_COMPONENTS_TOOL", "HAS_MENTION_RELATION", "OrganizationAgentNotFoundError", "OrganizationBlockNotFoundError", @@ -87,8 +77,6 @@ "RECORD_SUPERSESSION_TOOL", "REFERS_TO_RELATION", "REFINES_RELATION", - "RESOLVER_TOOL", - "RETRIEVE_TOOL", "RUMINATION_CAPABILITY", "RUMINATION_CONFIG_KEY", "RUMINATION_CONFIG_SCHEMA", diff --git a/app/business/organization/tools.py b/app/business/organization/tools.py index b385d7f7..d2a1f245 100644 --- a/app/business/organization/tools.py +++ b/app/business/organization/tools.py @@ -2,23 +2,17 @@ from __future__ import annotations -import asyncio -import dataclasses import typing import pydantic from app.business.agent import AgentManager, ToolExecutionError -from app.business.graph_navigation_retrieval import GraphNavigationRetrievalManager from app.business.info_base import InfoBaseManager -from app.business.info_base.services import BlockService, get_entity_records from app.business.info_base.commands import submit_graph as persist_submitted_graph from app.business.info_base.resolver import ( ResolverDraftCapability, ResolverManager, ) -from app.business.lexical_retrieval import LexicalRetrievalManager -from app.business.semantic_retrieval import SemanticRetrievalManager from app.schemas.ai import JSONValue from app.schemas.organization import ( DraftGraphInput, @@ -29,17 +23,8 @@ DuplicateAssertionProposal, EvidenceStanceProposal, ExistingReferentAnchorProposal, - GetEntitiesInput, - EntityNeighborhoodInput, - FindPathInput, - ConnectedComponentsInput, - OrganizationRetrieveInput, RecordOrganizationCandidateInput, RefinementProposal, - ResolverMetaToolInput, - ResolverDescribeInput, - ResolverInvokeInput, - ResolverMethodCall, SupersessionProposal, SynthesisProposal, ) @@ -59,12 +44,6 @@ GET_DRAFT_GRAPH_SCHEMA_TOOL = "get_draft_graph_schema" DRAFT_GRAPH_TOOL = "draft_graph" SUBMIT_GRAPH_TOOL = "submit_graph" -RETRIEVE_TOOL = "retrieve" -RESOLVER_TOOL = "resolver" -GET_ENTITIES_TOOL = "get_entities" -GET_ENTITY_NEIGHBORHOOD_TOOL = "get_entity_neighborhood" -FIND_PATH_TOOL = "find_path" -GET_CONNECTED_COMPONENTS_TOOL = "get_connected_components" RECORD_SUPERSESSION_TOOL = "record_supersession" RECORD_REFINEMENT_TOOL = "record_refinement" RECORD_EVIDENCE_STANCE_TOOL = "record_evidence_stance" @@ -73,8 +52,6 @@ RECORD_DUPLICATE_ASSERTION_TOOL = "record_duplicate_assertion" RECORD_ORGANIZATION_CANDIDATE_TOOL = "record_organization_candidate" -_JSON_ADAPTER = pydantic.TypeAdapter(JSONValue) - def _draft_capability_snapshot() -> dict[str, ResolverDraftCapability]: return { @@ -195,362 +172,6 @@ async def submit_graph(input: SubmitGraphInput) -> JSONValue: return typing.cast(JSONValue, result.model_dump(mode="json")) -@AgentManager.tool( - RETRIEVE_TOOL, - description="Retrieve lexical, semantic, or separate hybrid results for one query.", -) -async def retrieve(input: OrganizationRetrieveInput) -> JSONValue: - async def lexical() -> JSONValue: - result = await LexicalRetrievalManager.retrieve_local( - input.query, - input.limit, - ) - return typing.cast( - JSONValue, - { - "matches": [ - { - "entity": {"entity_type": "block", "entity_id": match.block.id}, - **match.model_dump(mode="json", exclude={"block"}), - } - for match in result.matches - ] - }, - ) - - async def semantic() -> JSONValue: - from app.schemas.semantic_retrieval import VectorRetrievalOptions - - result = await SemanticRetrievalManager.retrieve_local( - input.query, - options=VectorRetrievalOptions(limit=input.limit), - ) - return typing.cast( - JSONValue, - { - **result.model_dump(mode="json", exclude={"matches"}), - "matches": [ - { - "entity": {"entity_type": match.type, "entity_id": match.entity.id}, - "score": match.score, - } - for match in result.matches - ], - }, - ) - - branches = ( - ("lexical", lexical), - ("semantic", semantic), - ) - selected = ( - branches - if input.mode == "hybrid" - else tuple(branch for branch in branches if branch[0] == input.mode) - ) - outcomes = await asyncio.gather( - *(operation() for _, operation in selected), - return_exceptions=True, - ) - return { - name: ( - {"error": type(outcome).__name__, "message": str(outcome)} - if isinstance(outcome, BaseException) - else outcome - ) - for (name, _), outcome in zip(selected, outcomes, strict=True) - } - - -def _resolver_input_model() -> type[pydantic.BaseModel]: - common = ResolverManager.get_common_method_contracts() - variants: list[type[pydantic.BaseModel]] = [] - contracts = list(common) - for resolver_type in ResolverManager.RESOLVER_CLS: - contracts.extend( - contract - for contract in ResolverManager.get_method_contracts(resolver_type) - if contract.name in {item.name for item in common} - ) - seen: set[tuple] = set() - for contract in contracts: - signature = ( - contract.name, - tuple( - (name, repr(field.annotation), repr(field.default), repr(field.metadata)) - for name, field in contract.input_model.model_fields.items() - ), - ) - if signature in seen: - continue - seen.add(signature) - variants.append( - pydantic.create_model( - f"{contract.name}_Call_{len(variants)}", - __config__=pydantic.ConfigDict(extra="forbid"), - block_id=(int, ...), - method=( - typing.cast(typing.Any, typing.Literal)[contract.name], - pydantic.Field(description=contract.description), - ), - arguments=( - contract.input_model, - ... - if any( - field.is_required() for field in contract.input_model.model_fields.values() - ) - else pydantic.Field(default_factory=contract.input_model), - ), - ) - ) - - variants.append( - pydantic.create_model( - "ExtraMethodCall", - __base__=ResolverMethodCall, - method=( - str, - pydantic.Field( - json_schema_extra={"not": {"enum": [contract.name for contract in common]}} - ), - ), - ) - ) - call_type = typing.cast(typing.Any, typing.Union)[tuple(variants)] - invoke = pydantic.create_model( - "BoundResolverInvokeInput", - __base__=ResolverInvokeInput, - calls=(tuple[call_type, ...], pydantic.Field(min_length=1, max_length=20)), - ) - envelope = pydantic.create_model( - "ResolverEnvelope", - __base__=ResolverDescribeInput, - action=(typing.Literal["describe", "invoke"], ...), - calls=(tuple[call_type, ...], pydantic.Field(default=(), max_length=20)), - ) - - documented = pydantic.RootModel[ - typing.Annotated[ResolverDescribeInput | invoke, pydantic.Field(discriminator="action")] - ] - - class BoundResolverInput(ResolverMetaToolInput): - @classmethod - def model_json_schema(cls, *args, **kwargs) -> dict[str, typing.Any]: - # Method arguments are validated once by their actual Resolver owner, per - # call. A bad method argument must not discard successful batch siblings. - schema = documented.model_json_schema(*args, **kwargs) - # Some providers infer parameter types only from top-level properties. - # The union owns conditional validation; this is its wider envelope. - visible = envelope.model_json_schema(*args, **kwargs) - schema.update(type="object", properties=visible["properties"]) - schema.setdefault("$defs", {}).update(visible.get("$defs", {})) - return schema - - return BoundResolverInput - - -@AgentManager.tool( - RESOLVER_TOOL, - input_model_factory=_resolver_input_model, - description="Describe or invoke public typed read methods on exact Block Resolvers.", -) -async def resolver(input: ResolverMetaToolInput) -> JSONValue: - request = input.root - if request.action == "describe": - found = await BlockService.get_many(request.block_ids) - resolver_ids = set(request.resolver_types) - resolver_ids.update(block.resolver for block in found) - if not request.block_ids and not request.resolver_types: - resolver_ids.update(ResolverManager.RESOLVER_CLS) - return typing.cast( - JSONValue, - { - "results": [ - { - "resolver": resolver_id, - "methods": [ - { - "name": contract.name, - "description": contract.description, - "input_schema": contract.input_schema, - } - for contract in ResolverManager.get_method_contracts(resolver_id) - ], - } - for resolver_id in sorted(resolver_ids) - if resolver_id in ResolverManager.RESOLVER_CLS - ], - "missing_blocks": sorted(set(request.block_ids) - {block.id for block in found}), - "missing_resolvers": sorted( - resolver_id - for resolver_id in resolver_ids - if resolver_id not in ResolverManager.RESOLVER_CLS - ), - }, - ) - - results: list[JSONValue] = [] - for index, call in enumerate(request.calls): - block = await BlockService.get(call.block_id) - if block is None: - results.append( - { - "index": index, - "block_id": call.block_id, - "method": call.method, - "error": "not_found", - } - ) - continue - contract = ResolverManager.get_method_contract(block.resolver, call.method) - if block.resolver not in ResolverManager.RESOLVER_CLS: - results.append( - { - "index": index, - "block_id": call.block_id, - "method": call.method, - "error": "resolver_unavailable", - "message": f"Resolver {block.resolver!r} is not registered.", - } - ) - continue - if contract is None: - results.append( - { - "index": index, - "block_id": call.block_id, - "method": call.method, - "error": "method_unavailable", - "message": "Method does not exist; use describe for available method contracts.", - "available_methods": [ - item.name for item in ResolverManager.get_method_contracts(block.resolver) - ], - } - ) - continue - try: - value = await ResolverManager.invoke_method( - block, - call.method, - call.arguments.model_dump() - if isinstance(call.arguments, pydantic.BaseModel) - else typing.cast(dict[str, typing.Any], call.arguments), - ) - projected = _project_json(value) - except pydantic.ValidationError as error: - results.append( - typing.cast( - JSONValue, - { - "index": index, - "block_id": call.block_id, - "method": call.method, - "error": "invalid_arguments", - "fields": error.errors( - include_url=False, include_context=False, include_input=False - ), - "input_schema": contract.input_schema, - }, - ) - ) - except Exception as error: - results.append( - { - "index": index, - "block_id": call.block_id, - "method": call.method, - "error": type(error).__name__, - "message": str(error), - } - ) - else: - results.append( - { - "index": index, - "block_id": call.block_id, - "method": call.method, - "result": projected, - } - ) - return typing.cast(JSONValue, {"results": results}) - - -@AgentManager.tool( - GET_ENTITIES_TOOL, - description=( - "Read persisted Blocks or Relations without resolving content. " - "Null may indicate an incorrect entity type." - ), -) -async def get_entities(input: GetEntitiesInput) -> JSONValue: - if not input.entities: - return _project_json(await BlockService.get_random_many(input.random_count)) - blocks_by_id, relations_by_id = await get_entity_records( - tuple(ref.id for ref in input.entities if ref.type == "block"), - tuple(ref.id for ref in input.entities if ref.type == "relation"), - ) - return _project_json( - [ - blocks_by_id.get(ref.id) if ref.type == "block" else relations_by_id.get(ref.id) - for ref in input.entities - ] - ) - - -@AgentManager.tool( - GET_ENTITY_NEIGHBORHOOD_TOOL, - description=( - "Read a Block's direct neighborhood or a Relation with its endpoints. " - "Null may indicate an incorrect entity type." - ), -) -async def get_entity_neighborhood(input: EntityNeighborhoodInput) -> JSONValue: - request = input.root - if request.entity_type == "block": - result = await GraphNavigationRetrievalManager.get_block_neighborhood( - request.entity_id, - direction=request.direction, - contents=request.contents, - limit=request.limit, - cursor=request.cursor, - ) - else: - result = await GraphNavigationRetrievalManager.get_relation_neighborhood( - request.entity_id, - ) - return _project_json(result) - - -@AgentManager.tool( - FIND_PATH_TOOL, - description="Find a bounded graph path; an exploration limit is not proof of absence.", -) -async def find_path(input: FindPathInput) -> JSONValue: - result = await GraphNavigationRetrievalManager.find_path( - input.from_block_id, - input.to_block_id, - direction=input.direction, - contents=input.contents, - max_hops=input.max_hops, - max_explored_blocks=input.max_explored_blocks, - ) - return _project_json(result) - - -@AgentManager.tool( - GET_CONNECTED_COMPONENTS_TOOL, - description=( - "Partition seeds by bounded undirected reachability through exact Relation contents." - ), -) -async def get_connected_components(input: ConnectedComponentsInput) -> JSONValue: - return await _exact_result( - GraphNavigationRetrievalManager.get_connected_components( - **input.model_dump(), - ) - ) - - def _candidate_input_model() -> type[pydantic.BaseModel]: snapshot = {behavior.__rsotype__: behavior for behavior in behavior_resolver_classes()} if not snapshot: @@ -719,36 +340,3 @@ async def record_organization_candidate( return await _exact_result( typing.cast(typing.Any, behavior).record_candidate(input.block_id) ) - - -def _project_json(value: typing.Any) -> JSONValue: - if _contains_bytes(value): - raise TypeError("Binary Resolver values are unavailable through this Agent Tool") - if isinstance(value, pydantic.BaseModel): - projected = value.model_dump(mode="json") - elif dataclasses.is_dataclass(value) and not isinstance(value, type): - projected = dataclasses.asdict(value) - else: - projected = pydantic.TypeAdapter(typing.Any).dump_python( - typing.cast(typing.Any, value), - mode="json", - ) - return _JSON_ADAPTER.validate_python(projected) - - -def _contains_bytes(value: typing.Any) -> bool: - if isinstance(value, bytes): - return True - if isinstance(value, pydantic.BaseModel): - return any( - _contains_bytes(getattr(value, field)) for field in value.__class__.model_fields - ) - if dataclasses.is_dataclass(value) and not isinstance(value, type): - return any( - _contains_bytes(getattr(value, field.name)) for field in dataclasses.fields(value) - ) - if isinstance(value, dict): - return any(_contains_bytes(item) for item in value.values()) - if isinstance(value, list | tuple | set | frozenset): - return any(_contains_bytes(item) for item in value) - return False diff --git a/app/business/sink/AGENTS.md b/app/business/sink/AGENTS.md index d81681c5..1c6b69a3 100644 --- a/app/business/sink/AGENTS.md +++ b/app/business/sink/AGENTS.md @@ -1,6 +1,6 @@ # sink/ Local Guide -本目录实现 deployment-owned Sink type/instance lifecycle 与首个 MCP projection。跨 Unit authority 见 +本目录实现 deployment-owned Sink type/instance lifecycle 与具体 Sink projections。跨 Unit authority 见 [business-pipeline-and-authority.md](../../../docs/30-unit-tdd/business-pipeline-and-authority.md),MCP 具体 contract 见 [mcp-sink.md](../../../docs/30-unit-tdd/mcp-sink.md)。 @@ -13,6 +13,16 @@ - Enable/disable 先持久化当前 Peer intent,再 start/close local runtime。失败保持可观察且不反向改写 durable intent。 - 不增加 generic deliver、reconcile、transport、restart 或 rollback interface;具体 Sink 直接实现 `on_start/on_close`。 - Sink 只投影既有 use behavior,不取得 info-base、retrieval、Resolver 或 Storage authority。 +- SinkBase hooks 默认 no-op;只有拥有 active resource 的类型实现它们。Agent Query instance 挂载 exact REST + route,MCP instance 挂载 MCP endpoint,两者都由 SinkManager 的同一实例 lifecycle 启停。 + +## Agent Query Boundary + +- `core.agent-query.v1` config 只选择一个 Agent definition;prompt、model、tools 和 model-call budget 留在 Agent。 +- `/sinks/{id}/query` 异步创建 `core.sink.agent-query.v1` Job,不在 HTTP request 内运行模型。 +- `submit_query_result` 是 Sink-owned delivery Tool。读取工具仍由 info-base、Resolver 和 Graph Navigation 拥有。 +- result 保存在 Job state,不建立额外 query/result table。正常结束但未提交结果是明确失败;已经闭合的提交在 + 后续失败或取消时尽力保留。 ## MCP Boundary diff --git a/app/business/sink/__init__.py b/app/business/sink/__init__.py index 043ac73c..f79667da 100644 --- a/app/business/sink/__init__.py +++ b/app/business/sink/__init__.py @@ -10,12 +10,14 @@ ) from .main import SinkManager from .mcp import MCPSink +from .agent_query import AgentQuerySink __all__ = [ "DuplicateSinkRegistrationError", "SinkBase", "SinkError", "SinkManager", + "AgentQuerySink", "MCPSink", "SinkNotFoundError", "SinkStateConflictError", diff --git a/app/business/sink/agent_query.py b/app/business/sink/agent_query.py new file mode 100644 index 00000000..a9913bc1 --- /dev/null +++ b/app/business/sink/agent_query.py @@ -0,0 +1,220 @@ +"""Built-in Agent Query Sink, durable execution, and result delivery.""" + +from __future__ import annotations + +import typing + +import fastapi +import pydantic +from starlette.routing import BaseRoute + +from app.business.agent import AgentManager +from app.business.job import JobHandler, JobManager +from app.middleware import require_peer_jwt +from app.persistence.job.uow import JobUnitOfWork +from app.schemas.ai import ( + AssistantMessage, + JSONValue, + TextContentPart, + ToolResultMessage, + UserMessage, +) +from app.schemas.job import JobModel +from app.schemas.sink import SinkModel + +from .base import SinkBase +from .errors import SinkNotFoundError, SinkStateConflictError +from .main import SinkManager + + +AGENT_QUERY_SINK_TYPE = "core.agent-query.v1" +AGENT_QUERY_JOB_TYPE = "core.sink.agent-query.v1" +SUBMIT_QUERY_RESULT_TOOL = "submit_query_result" + + +class AgentQueryConfig(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + agent: int + + +class AgentQueryReference(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + type: typing.Literal["block", "relation"] + id: int + + +class AgentQueryResult(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + answer: str = pydantic.Field(min_length=1) + references: tuple[AgentQueryReference, ...] = () + + +class AgentQueryRequest(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + query: str = pydantic.Field(min_length=1) + timeout_seconds: int | None = pydantic.Field(default=None, gt=0) + + +class AgentQueryJobParameters(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + sink: int + query: str = pydantic.Field(min_length=1) + + +class AgentQueryResultMissingError(RuntimeError): + """A normal Agent Turn ended without delivering its required result.""" + + +@AgentManager.tool( + SUBMIT_QUERY_RESULT_TOOL, + description="Submit the answer to this information need and its supporting entities.", +) +async def submit_query_result(input: AgentQueryResult) -> JSONValue: + return typing.cast(JSONValue, input.model_dump(mode="json")) + + +class AgentQuerySink( + SinkBase[AgentQueryConfig], + sink_type=AGENT_QUERY_SINK_TYPE, + config_cls=AgentQueryConfig, +): + """Answer information needs by composing registered info-base read tools.""" + + def __init__(self, model: SinkModel) -> None: + super().__init__(model) + self._app: fastapi.FastAPI | None = None + self._routes: tuple[BaseRoute, ...] = () + + async def on_start(self, app: fastapi.FastAPI) -> None: + sink_id = typing.cast(int, self.model.id) + router = fastapi.APIRouter(dependencies=[fastapi.Depends(require_peer_jwt)]) + + @router.post( + f"/sinks/{sink_id}/query", + status_code=fastapi.status.HTTP_202_ACCEPTED, + name=f"agent_query_{sink_id}", + ) + async def create_query( + body: AgentQueryRequest, + request: fastapi.Request, + response: fastapi.Response, + background: fastapi.BackgroundTasks, + ) -> JobModel: + job = await JobManager.create( + AGENT_QUERY_JOB_TYPE, + {"sink": sink_id, "query": body.query}, + body.timeout_seconds, + ) + response.headers["Location"] = str(request.url_for("get_job", job_id=job.id)) + background.add_task(JobManager.notify_worker) + return job + + # include_router adds app-owned objects (included-router wrappers in FastAPI + # 0.139), not router.routes themselves. Capture those exact objects without + # relying on their private type; this synchronous section cannot interleave. + existing = {id(route) for route in app.router.routes} + app.include_router(router) + self._routes = tuple(route for route in app.router.routes if id(route) not in existing) + # CLI schema discovery must reflect enable/disable, not a cached route set. + app.openapi_schema = None + self._app = app + + async def on_close(self) -> None: + if self._app is not None and self._routes: + # Identity removes only this instance's publication, never another route + # sharing a path. MCPSink owns its directly appended Mount the same way. + owned = {id(route) for route in self._routes} + self._app.router.routes[:] = [ + route for route in self._app.router.routes if id(route) not in owned + ] + self._app.openapi_schema = None + self._routes = () + self._app = None + + async def can_execute(self) -> bool: + return await AgentManager.can_execute(self.config.agent, "text") + + async def execute(self, job: JobModel, query: str) -> None: + thread = await AgentManager.run( + self.config.agent, + UserMessage(content=(TextContentPart(text=query),)), + ) + turn = thread.current_turn + if turn is None: # pragma: no cover - AgentManager.run invariant + raise RuntimeError("Agent Query Thread has no active Turn") + try: + termination = await turn + finally: + result = _last_query_result(thread.messages) + if result is not None: + job.state = {**job.state, "result": result} + job.state = {**job.state, "termination": termination.value} + if result is None: + raise AgentQueryResultMissingError( + "Agent Query completed without submit_query_result" + ) + + +class AgentQueryJobHandler( + JobHandler[AgentQueryJobParameters], + job_type=AGENT_QUERY_JOB_TYPE, + description="Answer one information need through an enabled Agent Query Sink.", + parameters_model=AgentQueryJobParameters, + default_timeout_seconds=300, +): + @classmethod + async def normalize_parameters( + cls, + parameters: dict[str, typing.Any], + uow: JobUnitOfWork, + ) -> dict[str, typing.Any]: + normalized = await super().normalize_parameters(parameters, uow) + sink = await SinkManager.get(normalized["sink"]) + if sink.type != AGENT_QUERY_SINK_TYPE: + raise SinkStateConflictError( + f"Sink {sink.id} is not an {AGENT_QUERY_SINK_TYPE} instance" + ) + return normalized + + @classmethod + async def can_handle(cls, parameters: AgentQueryJobParameters) -> bool: + sink = SinkManager.get_running(parameters.sink) + return isinstance(sink, AgentQuerySink) and await sink.can_execute() + + @classmethod + async def handle(cls, job: JobModel, parameters: AgentQueryJobParameters) -> None: + sink = SinkManager.get_running(parameters.sink) + if not isinstance(sink, AgentQuerySink): + raise SinkNotFoundError(f"Agent Query Sink {parameters.sink} is not running") + await sink.execute(job, parameters.query) + + +def _last_query_result( + messages: typing.Iterable[typing.Any], +) -> dict[str, JSONValue] | None: + """Read the last successful submission from closed Assistant/ToolResult pairs. + + Thread._execute_turn atomically appends the Assistant and its complete results + as adjacent messages, in ToolCall order. The last successful submit in that + order wins; a later failed submit never replaces an earlier successful result. + """ + sequence = tuple(messages) + selected: dict[str, JSONValue] | None = None + for index, message in enumerate(sequence[:-1]): + if not isinstance(message, AssistantMessage) or not message.tool_calls: + continue + results = sequence[index + 1] + if not isinstance(results, ToolResultMessage): + continue + submitted = { + call.id for call in message.tool_calls if call.tool == SUBMIT_QUERY_RESULT_TOOL + } + for result in results.results: + if result.tool_call_id in submitted and not result.is_error: + selected = typing.cast(dict[str, JSONValue], result.content) + return selected diff --git a/app/business/sink/base.py b/app/business/sink/base.py index bd923964..ffc80ff8 100644 --- a/app/business/sink/base.py +++ b/app/business/sink/base.py @@ -1,6 +1,5 @@ """Sink type and instance lifecycle contract.""" -import abc import typing import fastapi @@ -12,7 +11,7 @@ ConfigT = typing.TypeVar("ConfigT", bound=pydantic.BaseModel) -class SinkBase(abc.ABC, typing.Generic[ConfigT]): +class SinkBase(typing.Generic[ConfigT]): """One persisted Sink instance realized by the current Peer.""" __sinktype__: typing.ClassVar[SinkTypeID] @@ -41,11 +40,10 @@ def __init__(self, model: SinkModel) -> None: self.model = model self.config = typing.cast(ConfigT, self.__configcls__.model_validate(model.config)) - @abc.abstractmethod async def on_start(self, app: fastapi.FastAPI) -> None: """Publish active effects for this exact instance.""" + del app - @abc.abstractmethod async def on_close(self) -> None: """Withdraw active effects for this exact instance.""" diff --git a/app/business/sink/main.py b/app/business/sink/main.py index 6fb959eb..80b51e5f 100644 --- a/app/business/sink/main.py +++ b/app/business/sink/main.py @@ -1,5 +1,8 @@ """Persisted Sink catalog, instances, and Peer-local lifecycle.""" +from __future__ import annotations + +import asyncio import logging import typing @@ -28,6 +31,7 @@ class SinkManager: _SINK_CLASSES: dict[SinkTypeID, type["SinkBase"]] = {} _running: dict[SinkID, "SinkBase"] = {} + _locks: dict[SinkID, asyncio.Lock] = {} _app: fastapi.FastAPI | None = None @classmethod @@ -59,6 +63,11 @@ async def list_types(cls) -> tuple[SinkTypeModel, ...]: async with sink_uow() as repository: return await repository.list_types() + @classmethod + async def get_type(cls, sink_type: SinkTypeID) -> SinkTypeModel | None: + async with sink_uow() as repository: + return await repository.get_type(sink_type) + @classmethod async def list(cls) -> tuple[SinkModel, ...]: async with sink_uow() as repository: @@ -93,52 +102,53 @@ async def update_config( sink_id: SinkID, value: dict[str, typing.Any], ) -> SinkModel: - current = await cls.get(sink_id) - sink_cls = cls._require_type(current.type) - validated = sink_cls.__configcls__.model_validate(value) - normalized = validated.model_dump(mode="json") - async with sink_uow() as repository: - sink = await repository.get(sink_id) - if sink is None: - raise SinkNotFoundError(f"Sink {sink_id} does not exist") - sink.config = normalized - await repository.save(sink) - running = cls._running.get(sink_id) - if running is not None: - running.update_config(validated) - return sink + async with cls._lock(sink_id): + current = await cls.get(sink_id) + sink_cls = cls._require_type(current.type) + validated = sink_cls.__configcls__.model_validate(value) + normalized = validated.model_dump(mode="json") + async with sink_uow() as repository: + sink = await repository.get(sink_id) + if sink is None: + raise SinkNotFoundError(f"Sink {sink_id} does not exist") + sink.config = normalized + await repository.save(sink) + running = cls._running.get(sink_id) + if running is not None: + running.update_config(validated) + return sink @classmethod async def delete(cls, sink_id: SinkID) -> None: - async with sink_uow() as repository: - sink = await repository.get(sink_id) - if sink is None: - raise SinkNotFoundError(f"Sink {sink_id} does not exist") - if sink.enabled or sink_id in cls._running: - raise SinkStateConflictError("Disable the Sink before deleting it") - await repository.delete(sink) + async with cls._lock(sink_id): + async with sink_uow() as repository: + sink = await repository.get(sink_id) + if sink is None: + raise SinkNotFoundError(f"Sink {sink_id} does not exist") + if sink.enabled or sink_id in cls._running: + raise SinkStateConflictError("Disable the Sink before deleting it") + await repository.delete(sink) @classmethod async def enable(cls, sink_id: SinkID, peer: PeerRef) -> SinkModel: - sink = await cls._set_peer_enabled(sink_id, peer, True) - if sink_id in cls._running: + async with cls._lock(sink_id): + sink = await cls._set_peer_enabled(sink_id, peer, True) + if sink_id in cls._running: + return sink + if cls._app is None: + raise SinkStateConflictError("Sink runtime has not started") + sink_cls = cls._require_type(sink.type) + instance = sink_cls(sink) + await instance.on_start(cls._app) + cls._running[sink_id] = instance return sink - if cls._app is None: - raise SinkStateConflictError("Sink runtime has not started") - sink_cls = cls._require_type(sink.type) - instance = sink_cls(sink) - await instance.on_start(cls._app) - cls._running[sink_id] = instance - return sink @classmethod async def disable(cls, sink_id: SinkID, peer: PeerRef) -> SinkModel: - sink = await cls._set_peer_enabled(sink_id, peer, False) - running = cls._running.get(sink_id) - if running is not None: - await running.on_close() - cls._running.pop(sink_id, None) - return sink + async with cls._lock(sink_id): + sink = await cls._set_peer_enabled(sink_id, peer, False) + await cls._close_running(sink_id) + return sink @classmethod async def startup(cls, app: fastapi.FastAPI, peer: PeerRef) -> None: @@ -154,15 +164,31 @@ async def startup(cls, app: fastapi.FastAPI, peer: PeerRef) -> None: @classmethod async def shutdown(cls) -> None: - for sink_id, instance in tuple(cls._running.items())[::-1]: - try: - await instance.on_close() - except Exception: - logger.exception("Sink failed to close", extra={"sink": sink_id}) - else: - cls._running.pop(sink_id, None) + for sink_id in tuple(cls._running)[::-1]: + async with cls._lock(sink_id): + try: + await cls._close_running(sink_id) + except Exception: + logger.exception("Sink failed to close", extra={"sink": sink_id}) cls._app = None + @classmethod + def get_running(cls, sink_id: SinkID) -> "SinkBase" | None: + """Return this process's active instance without changing durable intent.""" + return cls._running.get(sink_id) + + @classmethod + async def _close_running(cls, sink_id: SinkID) -> None: + running = cls._running.get(sink_id) + if running is None: + return + await running.on_close() + cls._running.pop(sink_id, None) + + @classmethod + def _lock(cls, sink_id: SinkID) -> asyncio.Lock: + return cls._locks.setdefault(sink_id, asyncio.Lock()) + @classmethod def _require_type(cls, sink_type: SinkTypeID) -> type["SinkBase"]: sink_cls = cls._SINK_CLASSES.get(sink_type) diff --git a/app/database_contract/profile.py b/app/database_contract/profile.py index 757174b6..bfb56505 100644 --- a/app/database_contract/profile.py +++ b/app/database_contract/profile.py @@ -358,6 +358,21 @@ def _boolean(default: bool) -> JsonObject: AUTOMATIC_ORGANIZATION_PARAMETERS_SCHEMA, 1800, ), + JobTypeProfile( + "core.sink.agent-query.v1", + "Answer one information need through an enabled Agent Query Sink.", + { + "additionalProperties": False, + "properties": { + "query": {"minLength": 1, "title": "Query", "type": "string"}, + "sink": {"title": "Sink", "type": "integer"}, + }, + "required": ["sink", "query"], + "title": "AgentQueryJobParameters", + "type": "object", + }, + 300, + ), ) diff --git a/app/persistence/sink/repository.py b/app/persistence/sink/repository.py index 7f0ce31b..52cb3f11 100644 --- a/app/persistence/sink/repository.py +++ b/app/persistence/sink/repository.py @@ -30,6 +30,9 @@ async def list_types(self): await self._session.scalars(sqlmodel.select(SinkTypeModel).order_by(SinkTypeModel.id)) ) + async def get_type(self, sink_type: str) -> SinkTypeModel | None: + return await self._session.get(SinkTypeModel, sink_type) + async def list(self): return tuple( await self._session.scalars( diff --git a/app/routes/job.py b/app/routes/job.py index a9197e79..55d6fddd 100644 --- a/app/routes/job.py +++ b/app/routes/job.py @@ -6,6 +6,7 @@ from app.business.job import JobManager, UnknownJobTypeError from app.business.source.main import SourceNotFoundError, UnsupportedSourceCommandError +from app.business.sink import SinkError, SinkNotFoundError from app.schemas.job import JobCreateForm, JobModel, JobStatus, JobTypeModel from .validation import request_input @@ -63,7 +64,9 @@ async def create_job( job = await JobManager.create(body.type, body.parameters, body.timeout_seconds) except SourceNotFoundError as error: raise fastapi.HTTPException(404, str(error)) from error - except (UnknownJobTypeError, UnsupportedSourceCommandError) as error: + except SinkNotFoundError as error: + raise fastapi.HTTPException(404, str(error)) from error + except (UnknownJobTypeError, UnsupportedSourceCommandError, SinkError) as error: raise fastapi.HTTPException(422, str(error)) from error response.headers["Location"] = str(request.url_for("get_job", job_id=job.id)) background.add_task(JobManager.notify_worker) diff --git a/app/routes/sink.py b/app/routes/sink.py index abf9c8c3..6dc570d2 100644 --- a/app/routes/sink.py +++ b/app/routes/sink.py @@ -36,6 +36,14 @@ async def list_sink_types() -> tuple[SinkTypeModel, ...]: return await SinkManager.list_types() +@ROUTER.get("/sink-types/{type_}") +async def get_sink_type(type_: str) -> SinkTypeModel: + result = await SinkManager.get_type(type_) + if result is None: + raise fastapi.HTTPException(404, f"Sink type {type_!r} not found") + return result + + @ROUTER.get("/sinks") async def list_sinks() -> tuple[SinkModel, ...]: return await SinkManager.list() diff --git a/app/schemas/organization_behavior.py b/app/schemas/organization_behavior.py index 2a58c16f..43915bf9 100644 --- a/app/schemas/organization_behavior.py +++ b/app/schemas/organization_behavior.py @@ -4,17 +4,8 @@ import pydantic -from app.schemas.ai import JSONValue from app.schemas.graph_navigation_retrieval import ( - GraphDirection, GraphModel, - DEFAULT_NEIGHBORHOOD_LIMIT, - MAX_NEIGHBORHOOD_LIMIT, - DEFAULT_MAX_HOPS, - MAX_MAX_HOPS, - DEFAULT_MAX_EXPLORED_BLOCKS, - MAX_MAX_EXPLORED_BLOCKS, - DEFAULT_MAX_EXPLORED_RELATIONS, ) from app.schemas.info_base.block import BlockID, ResolverType from app.schemas.info_base.relation import RelationID @@ -153,165 +144,3 @@ class SupersessionLineage(pydantic.BaseModel): current_block_ids: tuple[BlockID, ...] truncated: bool cycle_detected: bool - - -RetrievalMode: typing.TypeAlias = typing.Literal["lexical", "semantic", "hybrid"] - - -class OrganizationRetrieveInput(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - query: str = pydantic.Field( - description="Lexical requires all query terms; semantic matches meaning." - ) - mode: RetrievalMode = "hybrid" - limit: int = pydantic.Field( - default=20, ge=1, le=20, description="Maximum matches per mode." - ) - - @pydantic.field_validator("query") - @classmethod - def non_empty_query(cls, value: str) -> str: - if not value.strip(): - raise ValueError("query must not be empty") - return value - - -class ResolverMethodCall(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - block_id: BlockID - method: str - arguments: dict[str, JSONValue] = pydantic.Field(default_factory=dict) - - -class ResolverDescribeInput(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - action: typing.Literal["describe"] - resolver_types: tuple[ResolverType, ...] = () - block_ids: tuple[BlockID, ...] = () - calls: tuple[ResolverMethodCall, ...] = pydantic.Field(default=(), max_length=0) - - -class ResolverInvokeInput(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - action: typing.Literal["invoke"] - resolver_types: tuple[ResolverType, ...] = pydantic.Field(default=(), max_length=0) - block_ids: tuple[BlockID, ...] = pydantic.Field(default=(), max_length=0) - calls: tuple[ResolverMethodCall, ...] = pydantic.Field(min_length=1, max_length=20) - - -class ResolverMetaToolInput( - pydantic.RootModel[ - typing.Annotated[ - ResolverDescribeInput | ResolverInvokeInput, pydantic.Field(discriminator="action") - ] - ] -): - pass - - -class EntityReference(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - type: typing.Literal["block", "relation"] - id: int - - -class GetEntitiesInput(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - entities: tuple[EntityReference, ...] = pydantic.Field( - default=(), - max_length=20, - description="Ordered results; missing IDs return null. Empty selects random Blocks.", - ) - random_count: int = pydantic.Field( - default=1, - ge=1, - le=20, - description="Maximum distinct random Blocks when entities is empty.", - ) - - @pydantic.model_validator(mode="after") - def validate_selection(self) -> typing.Self: - if self.entities and self.random_count != 1: - raise ValueError("random_count only applies when entities is empty") - return self - - -class BlockNeighborhoodInput(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - entity_type: typing.Literal["block"] - entity_id: BlockID - direction: GraphDirection = "both" - contents: tuple[str, ...] = pydantic.Field( - default=(), description="Exact Relation contents; empty means all." - ) - limit: int = pydantic.Field( - default=DEFAULT_NEIGHBORHOOD_LIMIT, ge=1, le=MAX_NEIGHBORHOOD_LIMIT - ) - cursor: RelationID | None = pydantic.Field( - default=None, description="Previous next_cursor." - ) - - -class RelationNeighborhoodInput(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - entity_type: typing.Literal["relation"] - entity_id: RelationID - - -class EntityNeighborhoodInput( - pydantic.RootModel[ - typing.Annotated[ - BlockNeighborhoodInput | RelationNeighborhoodInput, - pydantic.Field(discriminator="entity_type"), - ] - ] -): - model_config = pydantic.ConfigDict(json_schema_extra={"type": "object"}) - - @classmethod - def model_json_schema(cls, *args, **kwargs) -> dict[str, typing.Any]: - schema = super().model_json_schema(*args, **kwargs) - properties = BlockNeighborhoodInput.model_json_schema(*args, **kwargs)["properties"] - properties["entity_type"] = { - "type": "string", - "enum": [ - typing.get_args(branch.model_fields["entity_type"].annotation)[0] - for branch in (BlockNeighborhoodInput, RelationNeighborhoodInput) - ], - } - schema["properties"] = properties - return schema - - -class FindPathInput(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - from_block_id: BlockID - to_block_id: BlockID - direction: GraphDirection = "both" - contents: tuple[str, ...] = pydantic.Field( - default=(), description="Exact Relation contents; empty means all." - ) - max_hops: int = pydantic.Field(default=DEFAULT_MAX_HOPS, ge=0, le=MAX_MAX_HOPS) - max_explored_blocks: int = pydantic.Field( - default=DEFAULT_MAX_EXPLORED_BLOCKS, ge=1, le=MAX_MAX_EXPLORED_BLOCKS - ) - - -class ConnectedComponentsInput(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - seed_block_ids: tuple[BlockID, ...] - contents: tuple[str, ...] = pydantic.Field( - min_length=1, description="Exact Relation contents treated as undirected connections." - ) - max_explored_blocks: int = pydantic.Field(default=DEFAULT_MAX_EXPLORED_BLOCKS, ge=1) - max_explored_relations: int = pydantic.Field(default=DEFAULT_MAX_EXPLORED_RELATIONS, ge=1) diff --git a/cli/.changes/+agent-query-sink.added.md b/cli/.changes/+agent-query-sink.added.md new file mode 100644 index 00000000..6e59a55a --- /dev/null +++ b/cli/.changes/+agent-query-sink.added.md @@ -0,0 +1 @@ +Add Sink lifecycle management and asynchronous Agent Query commands. diff --git a/cli/README.md b/cli/README.md index 1961fdc8..8cf58635 100644 --- a/cli/README.md +++ b/cli/README.md @@ -47,6 +47,27 @@ inkcre-cli config schemas --json `connection` 只管理本机连接,`config` 管理远端 deployment config;Agent definition 由 `agent` 管理, 模型目录由 `ai` 发现。Extension 配置和运行目标使用 `extension`,不混入 connection。 +## Agent Query + +Agent Query Sink 用 AI 组合已有 info-base 检索、Resolver 读取和图导航,并通过异步 Job 交付答案。先从 +`ai models` 取得模型 ID,再采用 +[推荐 Agent definition](../docs/30-unit-tdd/agent-query-sink.md#definition-and-result-contract) 创建 Agent: + +```sh +inkcre-cli agent create --input agent-query-agent.json --json +inkcre-cli sink create --type core.agent-query.v1 --schema +inkcre-cli sink create --type core.agent-query.v1 \ + --input-json '{"nickname":"query","config":{"agent":1}}' --json +inkcre-cli sink enable 1 --json +inkcre-cli query --sink 1 --schema +inkcre-cli query --sink 1 '哪些材料讨论了 Agent 工具边界?' --timeout-seconds 300 --json +inkcre-cli job wait 1 --for 10s --json +``` + +示例 ID 必须替换为前一步回执中的实际值。`query` 的成功回执只表示 Job 已持久化;使用 `job wait` 或 +`job get` 读取最终 status,并从 `state.result.answer` 和 `state.result.references` 取得回答与依据。Sink disable +会撤下该实例的动态 query route,但不取消已经领取的 Job。 + ## 输出与续读 结果写 stdout,命令错误写 stderr。退出码 0 表示命令成功,1 表示远端/IO/部分结果失败,2 表示本机输入 diff --git a/cli/src/inkcre_cli/commands/sink.py b/cli/src/inkcre_cli/commands/sink.py new file mode 100644 index 00000000..e748f003 --- /dev/null +++ b/cli/src/inkcre_cli/commands/sink.py @@ -0,0 +1,150 @@ +"""Sink instance management and Agent Query admission.""" + +import click + +from ..command import Group, Invocation, common, input_options, load_input, segment +from ..schema import nested, request_schema + + +@click.group(cls=Group) +def sink(): + """管理 deployment Sink type 与当前 Peer 上的实例生命周期。""" + + +@sink.command() +@common +@click.pass_obj +def types(inv: Invocation): + """列出当前 Core 注册的 Sink type。""" + inv.show(inv.send("GET", "/sink-types")) + + +@sink.command("list") +@common +@click.pass_obj +def list_sinks(inv: Invocation): + """列出 deployment 的 Sink 实例。""" + inv.show(inv.send("GET", "/sinks")) + + +@sink.command() +@click.argument("sink_id", type=int) +@common +@click.pass_obj +def get(inv: Invocation, sink_id): + """读取一个 Sink 实例。""" + inv.show(inv.send("GET", f"/sinks/{sink_id}")) + + +@sink.command() +@click.option("--type", "type_id", required=True) +@input_options +@common +@click.pass_obj +def create(inv: Invocation, type_id, schema, input_file, input_json): + """创建 Sink;JSON 包含 nickname 与该 type 的 config。""" + if schema: + owner = inv.send("GET", "/sink-types/" + segment(type_id)) + return inv.show( + nested( + request_schema(inv.client, "/sinks", "POST", omit=("type",)), + "config", + owner["config_schema"], + ) + ) + inv.show( + inv.send( + "POST", + "/sinks", + body={**load_input(input_file, input_json), "type": type_id}, + selectors={"type": "--type"}, + ) + ) + + +@click.group(cls=Group) +def config(): + """读取或完整替换一个 Sink 的 config。""" + + +@config.command("get") +@click.argument("sink_id", type=int) +@common +@click.pass_obj +def get_config(inv: Invocation, sink_id): + """读取当前 config。""" + inv.show(inv.send("GET", f"/sinks/{sink_id}")["config"]) + + +@config.command("replace") +@click.argument("sink_id", type=int) +@input_options +@common +@click.pass_obj +def replace_config(inv: Invocation, sink_id, schema, input_file, input_json): + """按 Sink type 合同完整替换 config。""" + if schema: + record = inv.send("GET", f"/sinks/{sink_id}") + owner = inv.send("GET", "/sink-types/" + segment(record["type"])) + return inv.show(owner["config_schema"]) + inv.show( + inv.send( + "PUT", + f"/sinks/{sink_id}/config", + body=load_input(input_file, input_json), + ) + ) + + +sink.add_command(config) + + +@sink.command() +@click.argument("sink_id", type=int) +@common +@click.pass_obj +def enable(inv: Invocation, sink_id): + """在当前连接的 Peer 上启用实例。""" + inv.show(inv.send("POST", f"/sinks/{sink_id}/enable")) + + +@sink.command() +@click.argument("sink_id", type=int) +@common +@click.pass_obj +def disable(inv: Invocation, sink_id): + """在当前连接的 Peer 上禁用实例,不取消已领取 Job。""" + inv.show(inv.send("POST", f"/sinks/{sink_id}/disable")) + + +@sink.command() +@click.argument("sink_id", type=int) +@common +@click.pass_obj +def delete(inv: Invocation, sink_id): + """删除已禁用的 Sink 实例。""" + inv.show(inv.send("DELETE", f"/sinks/{sink_id}"), no_content=True) + + +@click.command() +@click.argument("question", required=False) +@click.option("--sink", "sink_id", required=True, type=int) +@click.option("--timeout-seconds", type=click.IntRange(min=1)) +@click.option("--schema", is_flag=True, help="只查询当前启用实例的输入合同") +@common +@click.pass_obj +def query(inv: Invocation, question, sink_id, timeout_seconds, schema): + """通过一个已启用的 Agent Query Sink 创建 Job。""" + route = f"/sinks/{sink_id}/query" + if schema: + return inv.show(request_schema(inv.client, route, "POST")) + if question is None: + raise click.UsageError("QUESTION 是必需的;或使用 --schema") + inv.show( + inv.send( + "POST", + route, + body={"query": question, "timeout_seconds": timeout_seconds}, + selectors={"query": "QUESTION", "timeout_seconds": "--timeout-seconds"}, + ) + ) diff --git a/cli/src/inkcre_cli/main.py b/cli/src/inkcre_cli/main.py index 86e499ce..ee1c91a6 100644 --- a/cli/src/inkcre_cli/main.py +++ b/cli/src/inkcre_cli/main.py @@ -8,7 +8,7 @@ from pydantic import ValidationError from .command import Group, Invocation, common -from .commands import agent, config, connection, extension, info, jobs, peer, source +from .commands import agent, config, connection, extension, info, jobs, peer, sink, source from .errors import CommandError from .output import compact @@ -40,6 +40,8 @@ def cli(): connection.connection, extension.extension, peer.peer, + sink.sink, + sink.query, ): cli.add_command(command) diff --git a/cli/src/inkcre_cli/schema.py b/cli/src/inkcre_cli/schema.py index 0e99d5b3..9807f62d 100644 --- a/cli/src/inkcre_cli/schema.py +++ b/cli/src/inkcre_cli/schema.py @@ -3,6 +3,8 @@ from copy import deepcopy from typing import Any +import click + from .http import CoreRESTClient @@ -10,11 +12,16 @@ def request_schema( client: CoreRESTClient, path: str, method: str, *, omit: tuple[str, ...] = () ) -> dict: document = client.request("GET", "/openapi.json", authenticated=False) - schema = deepcopy( - document["paths"][path][method.lower()]["requestBody"]["content"]["application/json"][ - "schema" - ] - ) + try: + schema = deepcopy( + document["paths"][path][method.lower()]["requestBody"]["content"]["application/json"][ + "schema" + ] + ) + except KeyError as error: + raise click.ClickException( + f"当前 Core 未发布 {method.upper()} {path} 的 JSON 输入 schema;确认能力或实例已启用" + ) from error if "$ref" in schema: schema = deepcopy(document["components"]["schemas"][schema["$ref"].rsplit("/", 1)[1]]) for name in omit: diff --git a/docs/30-unit-tdd/README.md b/docs/30-unit-tdd/README.md index 2c5ea23c..0dcb2f42 100644 --- a/docs/30-unit-tdd/README.md +++ b/docs/30-unit-tdd/README.md @@ -9,6 +9,7 @@ This directory owns expensive internal design truth for logical units delivered | [lexical-retrieval.md](lexical-retrieval.md) | Internal lexical projection, feature extraction, maintenance, and ranking mechanics | | [graph-navigation-retrieval.md](graph-navigation-retrieval.md) | Internal bounded neighborhood, path, endpoint-closure, and query mechanics | | [mcp-sink.md](mcp-sink.md) | Persisted Sink lifecycle, MCP projection, Resource and Resolver-method mechanics | +| [agent-query-sink.md](agent-query-sink.md) | Asynchronous Agent-composed query lifecycle, result delivery, and setup contract | | [mail-extension.md](mail-extension.md) | Mail identity, MIME materialization, collection, graph, and failure boundaries | | [memos-extension.md](memos-extension.md) | Memos adapter identity, graph grammar, persistence, and failure boundaries | | [rss-extension.md](rss-extension.md) | RSS adapter identity, collection lifecycle, reconciliation, and materialization | diff --git a/docs/30-unit-tdd/agent-query-sink.md b/docs/30-unit-tdd/agent-query-sink.md new file mode 100644 index 00000000..8ab1fd19 --- /dev/null +++ b/docs/30-unit-tdd/agent-query-sink.md @@ -0,0 +1,74 @@ +# Agent Query Sink + +## Purpose And Topology + +`core.agent-query.v1` lets an external caller ask one information need while an Agent composes existing info-base retrieval, +Resolver reads, and graph navigation. It is a Sink projection, not a new retrieval engine. + +```text +POST /sinks/{id}/query + -> core.sink.agent-query.v1 Job + -> AgentQueryJobHandler + -> enabled AgentQuerySink + -> AgentManager.run(config.agent, query) + -> owner-provided read Tools + -> submit_query_result + -> Job.state.result +``` + +The endpoint returns `202 Accepted`, the persisted Job, and its `Location`. It never waits for the model. The dynamic route +exists only while that Sink instance is enabled on the current Peer. Generic `/jobs` admission can create the same Job type; +creation does not promise that a locally capable Peer is running. + +## Definition And Result Contract + +Sink config contains only `{ "agent": }`. The Agent definition remains the single authority for system prompt, AI model, +tool set, nullable tool choice, and per-turn model-call budget. Agent Query does not seed an Agent or duplicate its definition. + +The recommended definition selects these exact tools: + +- `retrieve`, `get_entities`, and `resolver` for recall and content interpretation; +- `get_entity_neighborhood`, `find_path`, and `get_connected_components` for bounded graph exploration; +- `submit_query_result` for the final answer and supporting Block/Relation references. + +```json +{ + "name": "InKCre Agent Query", + "system_prompt": "Answer the user's information need from InKCre. Explore the available retrieval, entity, resolver, and graph tools as needed. Do not claim evidence you did not read. Finish by calling submit_query_result with a concise answer and the Block or Relation references that support it. If the available evidence is insufficient, say so explicitly and submit the references you did find.", + "model": 1, + "tools": [ + "retrieve", + "get_entities", + "resolver", + "get_entity_neighborhood", + "find_path", + "get_connected_components", + "submit_query_result" + ], + "tool_choice": "required", + "max_model_calls_per_turn": 8 +} +``` + +Replace `model` with an AI model ID available in the deployment. `required` is recommended because Agent Query requires an +explicit delivery Tool call; a provider may otherwise end with ordinary Assistant text. Providers that cannot represent +tool choice may use `null` and rely on the prompt, with missing delivery reported as a failed Job. A successful result has +non-empty `answer` and zero or more `references`, each `{ "type": "block" | "relation", "id": }`. + +## Execution And Ownership + +Claim eligibility requires the exact Sink instance to be running locally and its Agent/model/tools to be executable. The +handler then runs one in-memory Thread and awaits its current Turn. The last successful `submit_query_result` from a closed +Assistant/ToolResult pair becomes `Job.state.result`; ordinary termination is recorded separately. Normal completion without +a submission fails explicitly. A closed submission is retained best-effort if a later model call fails, the Job times out, or +execution is cancelled. + +The generic read tools are controllers beside their semantic owners. They adapt Agent input/result shapes and call ordinary +domain services; they do not move retrieval, Resolver, or graph behavior into Agent Query. `submit_query_result` alone is +Sink-owned because delivery is the Sink's behavior. + +`submit_query_result` participates in the explicit `register_core_agent_tools()` bootstrap. It is available before any +Sink instance is enabled, and registration does not depend on importing Sink REST routes. + +The first version reads text/JSON and already-derived media text. It does not invoke raw multimodal interpretation, create a +second query/result store, persist Thread history, or add a new Peer capability. diff --git a/docs/30-unit-tdd/business-pipeline-and-authority.md b/docs/30-unit-tdd/business-pipeline-and-authority.md index 28db86a0..228370d2 100644 --- a/docs/30-unit-tdd/business-pipeline-and-authority.md +++ b/docs/30-unit-tdd/business-pipeline-and-authority.md @@ -47,9 +47,10 @@ implementation direction; it must not redefine Peer wire behavior or shared capa - source 或 protocol adapter 负责 native shape 与 extension-owned canonical command 之间的映射, 但不是 persistence owner。 -### 3. Info-Base Owns Graph Persistence +### 3. Info-Base Owns Graph Persistence And Cross-Retrieval Reads -- `InfoBaseManager` 只拥有 graph-form normalization;`commands.py` 和 `services.py` 拥有 graph 用例。 +- `InfoBaseManager` 协调 graph-form normalization、producer graph command,以及不属于某一种 retrieval + 实现的跨模式检索和实体读取。`commands.py` 和 `services.py` 拥有具体 graph 用例与 Block/Relation CRUD。 - producer 可以提出 recursive `StarsGraphForm` 或 flat signed-ID `GraphForm`;normalization、block/relation insert 与 database-managed identity 由 info-base 协调。 - 多个 graph mutation 通过必需的 GraphUnitOfWork 组合;只有 persistence 层接触 raw session。 @@ -134,6 +135,10 @@ runtime 路径禁止同步 session、scoped session 和直接驱动连接;`scr - `AgentManager` 把一个 persisted Agent definition(system prompt、model、Tool set、nullable tool choice、per-turn model-call budget)绑定为可复用的 Thread runtime。它依赖 graph-blind AIManager,但 Tool handler 的领域能力由 调用方模块提供;Agent domain 本身不取得 organization、Resolver 或 graph authority。 +- Agent Tool handler 是语义 owner 附近的 controller:它拥有 Agent-facing input model、参数接合和 JSON + projection,并调用普通领域 service。领域 service 不接受 Agent message、prompt 或 Tool wire shape;Agent registry + 只拥有 exact ID 绑定、schema 暴露和执行机制。共享 projection helper 只能内聚表示机制,不能重新形成跨领域 + `agent_tools` 业务集合。 - Agent Tool input 由 Agent runtime 根据 handler 的 Pydantic model 只校验一次。一个 Turn 是消息历史的唯一 writer;并发 ToolCalls 只返回结果,完整 Assistant ToolCall + ToolResult batch 才原子追加到 Thread history。 - Thread persistence backend 拥有完整 Thread snapshot。当前只有 process-local in-memory backend;不存在独立的 @@ -168,10 +173,10 @@ runtime 路径禁止同步 session、scoped session 和直接驱动连接;`scr - 每个行为拥有一个独立 automatic Job 和 `core.organization.` deployment config。Job 只承担调度与运行管理; Resolver 读取候选、构造起始证据、调用所选 purpose-built Agent,并由 behavior-owned exact command 写普通 Block/Relation。 初始 seed 不限制 Agent 后续通过 retrieval、Resolver 或 graph navigation 继续探索。 -- `retrieve` 返回候选引用与已有命中信息;`get_entities` 读取普通持久实体,`resolver` 解释内容。 - `get_entity_neighborhood`、`find_path`、`get_connected_components` 直接投影 Graph Navigation 的少量稳定查询。 - Resolver method reflection 由 `ResolverManager` 拥有;公共读取方法直接进入 Agent schema,额外方法按需发现。 - MCP Sink 只投影同一 owner contract,不成为 Organization 的依赖,也不继承内部 Agent Tool 的请求包装。 +- Organization-owned Agent definitions 组合各领域提供的读取工具,而不拥有其合同。`retrieve` / `get_entities` + 由 info-base 拥有;`get_entity_neighborhood`、`find_path`、`get_connected_components` 由 Graph Navigation + 拥有;`resolver` 的 controller 位于 Resolver 领域,`ResolverManager` 发现与分派,exact Resolver instance + 执行。MCP Sink 只投影同一 owner contract,不成为 Organization 的依赖,也不继承内部 Agent Tool 的请求包装。 - 工具定义表达关系含义,字段名称保留所指实体身份;行为识别过程属于所选 Agent definition。 Resolver 的方法参数由实际 owner 逐调用验证,错误不丢弃同批其它结果。schema 由同一方法合同投影, 顶层分支同时显示字段形状,以兼容只从顶层 properties 推断参数类型的 provider。 @@ -219,6 +224,10 @@ runtime 路径禁止同步 session、scoped session 和直接驱动连接;`scr oversized/binary content 通过 live Resource URI 重新读取当前 authority,不产生 Resource table 或缓存 authority。 - MCP 的 read-only boundary 排除 Agent-intended mutation command;Resolver `get_*` / `read_*` 仍可按其既有 contract lazy materialize missing derivation,因此相关 Tool 不虚假声明绝对无副作用。 +- `core.agent-query.v1` 把一个 persisted Agent definition 绑定为异步 query instance;其动态 + `/sinks/{id}/query` 只创建 `core.sink.agent-query.v1` Job。Job handler 调用 Agent runtime,读取能力仍由各领域 + owner 提供,`submit_query_result` 由 Agent Query Sink 拥有。回答与支持它的 Block/Relation references 写入 Job + state;Sink 不建立第二套 query、Thread 或 result store。 ## Cross-Subtree Constraints diff --git a/docs/30-unit-tdd/graph-navigation-retrieval.md b/docs/30-unit-tdd/graph-navigation-retrieval.md index e9e16a92..e87826bc 100644 --- a/docs/30-unit-tdd/graph-navigation-retrieval.md +++ b/docs/30-unit-tdd/graph-navigation-retrieval.md @@ -25,6 +25,11 @@ become presentation hints. 查询入口均为异步业务方法,每次调用拥有独立的 GraphUnitOfWork。HTTP、MCP 与 Agent Tool 等待业务方法,不创建 session;内部遍历复用该次操作的 repositories,不接受可选 raw session。 +Agent-facing controllers 与输入模型由本领域拥有,并调用相同 Manager methods: +`get_entity_neighborhood`、`find_path`、`get_connected_components`。它们只负责 Agent 参数接合与 JSON +projection,不复制遍历逻辑,也不让 Manager 依赖 Agent runtime。Organization、MCP 和 Agent Query 只是这些 +能力的消费者。 + ## Query Mechanics Relation pages use `(from_, id DESC)` and `(to_, id DESC)` indexes. A `both` neighborhood intentionally diff --git a/docs/30-unit-tdd/organization.md b/docs/30-unit-tdd/organization.md index 9aeffcf4..4456fc52 100644 --- a/docs/30-unit-tdd/organization.md +++ b/docs/30-unit-tdd/organization.md @@ -58,19 +58,16 @@ keyed by exact text plus exact source basis; the ordinary Block identity rule is ## Reading and Agent boundary -Exploratory behavior definitions compose the following read tools: +Exploratory behavior definitions select the following owner-provided read tools: -- `retrieve(query, mode)` combines lexical/semantic entry without hiding their separate results; -- `get_entities(entities, random_count)` reads ordinary persisted records in request order; each reference carries its own - `type` and `id`, missing records return null, and an empty reference list selects random Blocks; -- `resolver` describes or invokes typed public `get_*`/`read_*` methods through `ResolverManager`; -- `get_entity_neighborhood`, `find_path`, and `get_connected_components` directly expose the small, stable query set owned by - `GraphNavigationRetrievalManager`. +- info-base owns `retrieve` and `get_entities`; +- the Resolver domain owns `resolver` discovery and invocation; +- Graph Navigation owns `get_entity_neighborhood`, `find_path`, and `get_connected_components`. -Common Resolver reads are visible in the invocation schema; additional methods are discoverable. ResolverManager owns method -contracts and invocation validation. An invalid invocation returns its error and available contract without discarding other -calls in the batch. Agent adapters serialize values and reject binary projection; they do not replace Resolver or Graph -Navigation APIs. MCP Sink consumes the same Resolver-owned reflection contract but Organization does not depend on MCP. +Organization owns only its behavior definitions, candidate selection, initial message, and behavior-specific mutation tools. +The generic read contracts and Agent-facing controllers remain with their semantic owners; Organization does not redefine +them merely because its Agents consume them. MCP Sink and Agent Query Sink may compose the same owner contracts without +becoming dependencies of Organization. Mutation tools are behavior-specific, except the single dynamic `record_organization_candidate` tool. Agent definitions—not an extra runtime allowlist—select the tools appropriate to each behavior. AgentManager and AIManager remain graph-blind execution @@ -102,10 +99,9 @@ budget exhaustion to its caller. These diagnostics use the existing application ## Graph use -`GraphNavigationRetrievalManager.get_connected_components()` partitions caller seeds by bounded undirected connectivity over -exact requested Relation contents. It returns discovered member Blocks, spanning proof Relations, missing seeds, and a truncation -flag. A truncated result cannot prove that separate provisional components are independent. Its first use law is counting one -`duplicates assertion` component as one provenance occurrence. +`get_connected_components` 的通用查询合同归 Graph Navigation。Duplicate Assertion 只拥有它的消费规则: +一个完整的 `duplicates assertion` component 计作一个 provenance occurrence;truncated 结果不能证明临时分组 +彼此独立。这里不重复定义遍历算法或返回模型。 `SupersessionBehaviorResolver.read_lineage()` follows `supersedes` relations in both directions from a focal Block and returns the bounded graph, current frontier, cycle detection, and truncation. A relation points from successor to predecessor: diff --git a/docs/30-unit-tdd/semantic-retrieval.md b/docs/30-unit-tdd/semantic-retrieval.md index baf43a20..cb3e4f18 100644 --- a/docs/30-unit-tdd/semantic-retrieval.md +++ b/docs/30-unit-tdd/semantic-retrieval.md @@ -10,9 +10,6 @@ This unit lets a caller rank existing info-base entities by semantic similarity. `RelationModel` rows plus scores; it does not create transient chunks, synthesize an answer, or repair the info-base while reading. -Rumination is included only as the minimum explicit organization path needed when a collected Block is too coarse for good -retrieval. It remains an additive graph command, not part of retrieval or collection. - ## Durable Facts And Runtime Owners ```text @@ -73,20 +70,6 @@ When a caller explicitly targets another Peer, the same facade delegates exact c Peer routing remains payload-opaque and only fails over after proven non-execution; an uncertain post-dispatch outcome stops. -## Rumination And Agent Boundary - -`RuminationBehaviorResolver.ruminate(block_id)` builds one initial message from the focal Resolver text and all direct relations -(one hop, without relation-count truncation). A deployment config chooses a persisted Agent definition. The Agent can discover selected Resolver -draft schemas, request a non-persisting Resolver draft, and submit one flat signed-ID `GraphForm`; only `submit_graph` may -write. - -The Agent definition persists system prompt, model, Tool set, nullable tool choice, and per-turn model-call budget. Thread -history and active Turn Tasks are currently process-local. The runtime validates Tool input once with the registered -Pydantic model, executes one ToolCall batch concurrently, and appends only a closed Assistant/ToolResult pair. - -Rumination preserves the focal graph, may no-op, and may add duplicates on repeated runs. It has no periodic trigger, -automatic retry, rollback, run record, checkpoint, freshness proof, or exactly-once layer. - ## Acceptance The checked-in acceptance corpus is owned by diff --git a/docs/_shared b/docs/_shared index 5d0d8d7f..42f7bad1 160000 --- a/docs/_shared +++ b/docs/_shared @@ -1 +1 @@ -Subproject commit 5d0d8d7facfe68227c5bbc93361ba77f4573e7fa +Subproject commit 42f7bad1c61e57b5e0ebf55e27815ddc2ae913fa diff --git a/docs/openapi.json b/docs/openapi.json index 139432d5..b8c1b200 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3934,6 +3934,48 @@ } } }, + "/sink-types/{type_}": { + "get": { + "tags": [ + "sink" + ], + "summary": "Get Sink Type", + "operationId": "get_sink_type_sink_types__type___get", + "parameters": [ + { + "name": "type_", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Type " + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SinkTypeModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/sinks": { "get": { "tags": [ diff --git a/run.py b/run.py index f72bca47..f192926e 100644 --- a/run.py +++ b/run.py @@ -82,6 +82,7 @@ async def bootstrap_runtime(app: fastapi.FastAPI) -> None: """Initialize database-backed runtime services after migrations are ready.""" from app.business.info_base.resolver import register_core_resolvers + from app.business.agent import register_core_agent_tools from app.business.organization import register_core_organization_behaviors from app.business.info_base.storage import StorageManager @@ -96,6 +97,7 @@ async def bootstrap_runtime(app: fastapi.FastAPI) -> None: # Core decoders exist independently of installed/enabled extensions. register_core_resolvers() register_core_organization_behaviors() + register_core_agent_tools() # Setup built-in storage instances await StorageManager.setup_builtin_storages_async() diff --git a/tasks/knowledge-lifecycle-capabilities/collaboration/roster.md b/tasks/knowledge-lifecycle-capabilities/collaboration/roster.md index 0617a343..73eb13b1 100644 --- a/tasks/knowledge-lifecycle-capabilities/collaboration/roster.md +++ b/tasks/knowledge-lifecycle-capabilities/collaboration/roster.md @@ -9,12 +9,13 @@ | `telegram-extension` | `01a04685-aa31-7682-a4a2-824727eacce5` | core-py PR #89 / `.github` PR #28 | merged as core-py `42d8527` and `.github` `f7269b9` | Closed / merged | D-421–D-460 | Telegram、repository-wide Changie→Towncrier cutover and organization guidance are complete;Unit worktrees are retired and Release PR #90 is independently owned by the release lifecycle | | `organization-nowledge-study` | current session | PR #100 / #101 merged;root worktree on main retains local task-control records | protected `main` `b3ccb00` | Closed / production delivered | D-461–D-570 | Core 0.2.0、production probes and stable admission complete under D-562;no active implementation ownership | | `cli-sink` | 当前 CLI session | `feat/cli-sink-closure` / root core-py worktree | production `4143abe`;关闭记录 PR #104 | Closed | D-571–D-610 | Sir 授权后依序合并;CLI 0.1.0 PyPI 安装与生产复验通过。无 active 源码 ownership;见 unit delivery.md | +| `agent-query-sink` | 当前 session | `feat/agent-query-sink` / root core-py worktree | `676886a` | Revised baseline / packet commit authorized | D-611–D-650 | 未授权源码实施;读取能力按领域 controller/service 分离,增加 Hub meta 文档准则与 Core authority 纠正;拟改 Core/CLI,共享 SinkManager 修正需回归 MCP | ## Shared-worktree coordination -MCP、Telegram 和 Organization 均已关闭;root worktree 保留 parent 生命周期内的本地 task-control 记录, -CLI 已关闭,当前 feature branch 仅整理关闭记录。CLI 保留上一单元的本地收尾和未跟踪 skill,不将其视为 -本 unit 的修改。没有其他已登记的 active unit,也不进行 cross-session 通信。Historical task-control and operational state can still intersect: +2026-09-20,root worktree 从干净且与 origin/main 一致的 `676886a4d2242be2f14465523c3267a126b60fd3` +进入 Agent Query Sink 产品设计。MCP、Telegram、Organization、CLI 已关闭;旧 dirty baseline 描述不代表现状。 +没有其他已登记的 active unit,也不进行 cross-session 通信。Historical task-control and operational state can still intersect: - `mcp-sink` has no remaining implementation ownership。Its Core、Extension Runtime and production changes are authoritative on protected `main` at `459a6df`;the root worktree's remaining dirty state is task control,not unmerged MCP source。 diff --git a/tasks/knowledge-lifecycle-capabilities/common-patterns/agent-tools.md b/tasks/knowledge-lifecycle-capabilities/common-patterns/agent-tools.md index 82be9220..75f1e795 100644 --- a/tasks/knowledge-lifecycle-capabilities/common-patterns/agent-tools.md +++ b/tasks/knowledge-lifecycle-capabilities/common-patterns/agent-tools.md @@ -18,6 +18,24 @@ 方法数量不是唯一标准。少量稳定方法不需要强行套元工具;开放能力也不能为工具少而被截成几个固定读取方法。 合并入口不能隐藏不同分支的实际语义、静默忽略不适用参数,或靠重合的数字 ID 猜实体类型。 +## 能力按领域归属,不按调用方式集中 + +工具暴露是领域能力的一种调用方式,不是独立的业务领域。一个能力被多个 Agent 或其他消费者使用时, +泛化应去掉偶然的消费方依赖,回到能力真正的 owner;不能仅因都能注册为工具就集中到 app/agent_tools。 +Sir 明确反对今后创建这一目录,亦不能换个名字继续维护跨领域工具业务集合。 + +实现、输入合同和必要的工具接合随对应领域组织;领域已有方法优先复用,缺少的组合行为放回领域 owner。 +注册、参数绑定、执行与通用序列化机制可以由 Agent 基建拥有,但 registry 不取得所注册能力的业务归属。 +也不反过来把协议包装、模型提示或工具消息结构强加为领域方法的参数。判断是否需要薄适配以真实接口差异 +为准,不为了“分层”复制调用链。领域内也须区分实例行为与跨实例发现/分派,不只根据工具名称选类。 + +Agent Tool handler 是 controller,领域方法是 service:controller 负责工具合同与输入/输出适配, +调用领域入口;领域负责业务组合与效果。runtime 根据工具 schema 校验输入,不让两层重复验证。 +按领域归属组织 controller,不等于将其装饰器、工具消息或 JSON 输出限制混入领域 service。工具 schema +可以不同于 service 参数;这种真实边界差异由薄 controller 处理,不必扩张注册框架或改造领域方法。 + +来源:D-629/D-630。后续实施时在已有本地架构文档中明确这一既有分层;讨论阶段先记录在 task packet。 + ## 可组合性 每个工具提供职责内完整且清楚的能力,输出保留下一项能力所需的可寻址引用。检索负责找到候选及命中信息, diff --git a/tasks/knowledge-lifecycle-capabilities/decisions/D611-D620.md b/tasks/knowledge-lifecycle-capabilities/decisions/D611-D620.md new file mode 100644 index 00000000..8997867e --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/decisions/D611-D620.md @@ -0,0 +1,92 @@ +# Decisions D-611–D-620 + +## D-611 — Agent Query Sink 的检索定位与 unit 启动 + +2026-09-20,Sir 选择 `agent-query-sink` 作为下一 implementable unit,并确认其边界:由 AI 组合既有 +info-base 原子查询能力,承担如何检索的判断,获得与目标相关的信息;不成为另一套索引/原子查询实现, +不默认扩展成写文章、生成设计方案等下游创作 Agent,也不替代 MCP、CLI 访问界面。 + +进入既定产品设计 → 技术设计 → 验收设计 → 实现计划/预演 → 授权实施 → 端到端验收流程。 +当前授权为调查、讨论与 unit packet 维护,不含源码实施或提交发布。结果形状、MVP 入口与执行体验仍待复核。 + +## D-612 — 可寻址信息为交付主体,解释与简短回答为辅助 + +Sir 接受:Agent Query 的结果以筛选后的相关信息为主体,保留真实 Block/Relation 引用,使 caller 能继续 +打开、读取和导航。允许附带说明相关性的依据、必要的对照或简短结论,以及尚未找到依据的部分; +不能只交付无法回到原始信息的生成文本,也不只返回将筛选负担推回 caller 的原始命中列表。 + +Agent 可根据中途所得信息改写查询、读取、追踪关系与筛选,不要求每次机械执行全部检索模式。 +验收应关注信息是否真正回应目标,而不是只看 Agent Turn 是否正常结束。具体输出 schema、调用旅程、 +MVP 消费入口仍未冻结;本项确认不授权源码实施。 + +## D-613 — 独立检索调用采用现有 Job 承载 + +Sir 确认以一次检索目标为单位,不要求 caller 与内部 Agent 维持持续对话,并建议基于 Job;核对既有 +Job 模型后采用该方向。一次调用内部可多次调用模型和查询能力;caller 后续补充条件可发起新检索, +不承诺隐式继承上次全部探索过程或跨重启续查。 + +复用 Job 的持久受理、执行端领取、查询、限时等待和 best-effort 停止,不因检索耗时新增另一套调度生命周期。 +Job 只承担执行管理,Agent Query 拥有请求/结果和检索策略,原子查询仍归既有 owner。 +结果存放位置、进行中成果、MVP 消费入口及具体字段仍按后续设计确认;此处不授权源码实施。 + +## D-614 — MVP 消费入口为普通 Core REST 与 CLI + +Sir 接受本轮以普通 Core REST + CLI 形成完整的 Agent Query 消费旅程,复用已有 Job 创建、查询、 +有界等待与停止能力;CLI 不在本机执行检索 Agent。MCP 和 client-web 的专门接入不纳入本轮。 + +本项只确定实现/验收需要覆盖的消费入口,不改变 Agent Query 作为检索 sink 的定位,也不改变既有 +原子检索和 MCP 工具。具体命令、请求/结果及内部执行结构仍待设计;不构成源码实施或发布授权。 + +## D-615 — Agent Query 使用 SinkBase 实例与 Sink 配置 + +Sir 保留 Job 驱动方向,但修正前一轮“不创建 SinkBase 实例”的建议:Agent Query 实现在 core-py 内, +应归属 Sink 实现,并通过现有 SinkBase/SinkManager 管理实例,相关 Agent/AI 选择由 sink.config 承载。 +不为此另建同义 deployment config。CLI 是独立产品 sink 的例子,不作为省去本地 Agent Query 实例的理由。 + +Agent definition 和 AI model 已有持久 owner;具体配置应优先表达引用,避免创建第二份权威。引用字段、 +执行时选择及 enable/disable 与 Job 的接合由技术方案继续明确。当前不授权源码实施。 + +## D-616 — Sink 引用 Agent,禁用不隐含取消已开始的 Job + +Sir 确认修正后的拓扑:Job 指定 Sink,AgentQuerySink 实例调用 AgentManager,读取工具再调用既有查询和 +Resolver。`sink.config.agent` 引用已有 Agent definition,模型由 definition.model 选择,不在 config 中 +重复存储 definition 或另设同义 model 配置。 + +禁用 Sink 后,此 Peer 不再领取新的相关 Job;已取得实例并开始的 Job 继续由自己的 abort/timeout 管理。 +“禁用能力”不隐含“取消所有正在执行的工作”。尚未开始的 Job 不因禁用被静默改绑到其它 Sink。 +具体实现中的资格/领取竞态仍按既有 best-effort 模型预演,不据此新增跨领域事务或锁。 + +## D-617 — 保留 Sink 主体,生命周期 hooks 允许默认无操作 + +Sir 同意局部收敛:保留 SinkBase 的注册、配置与实例契约,让 on_start/on_close 提供默认 no-op, +没有自有常驻资源的 Sink 不必实现空方法。MCP 继续覆盖 hooks 管理自身资源。暂时保留宿主 app 参数, +不为消除一个未使用参数新建 Context、HTTP Sink 子层或资源框架。此项是设计确认,不是源码实施授权。 + +## D-618 — Agent Query REST 受理检索,背后创建 Job + +Sir 纠正此前将“基于 Job 执行”推导为“只能通过普通 Job API 使用”的倒置。Agent Query 提供自己的 +REST API:caller 表达检索请求,入口实际创建 Agent Query 类型的 Job,使检索执行脱离 HTTP 请求的 +等待时长。通用 Job API 也允许直接创建同类型 Job;两者使用同一 Job admission 与执行合同。 + +本项修正 D-614 的过窄技术解释及此前无专用 endpoint 的建议,不改变 REST + CLI 消费范围、Sink 实例 +归属、既有 Job 控制或禁用不取消已开始工作。具体 path、响应以及 route 是否随实例挂载仍待技术确定; +不能从“存在 REST API”反推必须另建工作队列、结果表、轮询框架或 Sink 专属后台 worker。 + +## D-619 — REST 返回异步受理回执,后续复用 Job 控制 + +Sir 确认 Agent Query REST 成功受理后返回 `202 Accepted`、已创建的 Job,以及指向既有 Job 查询地址的 +`Location`。响应不等待检索完成,HTTP 请求结束不取消已持久受理的工作。之后复用 Job 查询、CLI 有界 +等待与停止;不新建同义任务控制机制。具体 route path 与挂载方式仍待确定,检索结果草案未因此获批。 + +## D-620 — 基于 info-base 回答信息需求,不以匹配实体为能力上限 + +Sir 确认提升 Agent Query 定位:围绕 caller 的问题,利用 info-base 形成有依据的回答。允许跨材料比较、 +解释、重建取舍/演变和推论;答案不必已经完整存在于某个 Block。实体引用是可核查依据与继续探索的入口, +不是输出内容的上限。找原文时可以直接交付材料,问问题时回答可以成为主体,不要求 caller 先选择模式。 + +本项修正 D-612 中“信息实体为主体、解释仅辅助”的上限,保留可寻址依据要求。区分原文陈述、Agent 推论 +与缺失依据;不把临时推导的联系冒充既有 Relation。不自动写回 organization 成果,也不默认接管文章、 +设计、代码或操作等下游工作。验收同时关注找对材料和回答是否回应问题、结论是否被证据支持。 + +原 matches + reason + optional summary 草案不再作为结果基线。新的结果合同仍待设计;不因产品提升 +增加外部研究、持续聊天或其它消费入口,也不改变已确认的 Sink/Job/REST/CLI 执行边界。 diff --git a/tasks/knowledge-lifecycle-capabilities/decisions/D621-D630.md b/tasks/knowledge-lifecycle-capabilities/decisions/D621-D630.md new file mode 100644 index 00000000..3fc10ee8 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/decisions/D621-D630.md @@ -0,0 +1,129 @@ +# Decisions D-621–D-630 + +## D-621 — Agent Query 交付 answer 与实体 references + +Sir 确认结果使用 `answer` 与 `references`。answer 用 Markdown 回应问题,可表达材料指引、比较、解释、 +条件化判断或信息缺口,不固定报告模板。正文在对应判断附近标明实体依据,references 用 `{type, id}` +保留机器可用的 Block/Relation 引用,不列出全部探索候选、不另建 citation ID。 + +不再要求逐实体 reason、独立 summary 或自报置信度。Agent 的任务目标与验收应落实 D-620,不只是把 +检索摘要拼成回答。结果如何提交、保存,以及中断时保留什么仍待设计,本项不授权源码实施。 + +## D-622 — 普通结果提交工具与 Job 收尾尽力保存 + +Sir 确认使用 Agent Query-owned `submit_query_result(answer, references)` 普通工具。现有 Agent runtime +负责一次 Pydantic 输入校验,工具返回结构化值并进入 Thread 历史;Sink 提取成功提交,Job handler +保存至 job.state.result。工具不直接写 graph 或 Job,不要求模型填写 job_id,不改通用 Agent runtime。 + +多次提交使用最后一次成功结果,后续失败不抹掉先前结果;最终 Assistant 自由文字不是另一份结果。 +停止、超时或异常时尽力保存已经进入历史的回答,保留 Job 的真实终态,不补跑总结。未提交不能解释为 +没有找到信息。工具调用成功不是持久化保证:Thread 内存、未闭合批次、进程崩溃、数据库收尾失败等 +均可能使回答未落库。MVP 接受这一 best-effort 边界,不增加实时结果发布、持久 Thread 或恢复机制。 + +具体终止原因在 Job state 中如何呈现,以及对正常结束却未交付结果的状态映射,仍在技术细化范围内。 + +## D-623 — Agent Query endpoint 随 Sink 实例挂载与撤下 + +Sir 接受前轮接口形状,但否决固定存在的 Agent Query route。`POST /sinks/{sink_id}/query` 属于该 +AgentQuerySink 实例:on_start 挂载,on_close 撤下。请求为 query 与可选 timeout_seconds;保持 +D-619 的 202/Job/Location。CLI query 选择 Sink 并发起请求,后续复用 job get/wait/abort。 + +“Job 的受理与执行分离”不决定其 caller endpoint 的生命周期。通用 Job API 独立存在,可创建相同类型 +任务;两入口可以复用同一 admission 合同,但不必具有相同的可用性。撤下 Sink endpoint 不删除、 +取消已受理的 Job,不撤下通用 Job 查询/控制接口。执行端资格仍由已确认的 Sink enable 与 Job claim +合同决定。D-617 的默认 no-op hooks 保留,但 AgentQuerySink 因拥有 endpoint 而应覆盖它们。 + +此前“在任何接入 Peer 都可通过专用固定 route 受理”的建议撤回;不得通过隐藏禁用检查的固定 route +替代实际挂载/撤下,也不因此增加通用 Sink endpoint framework。 + +## D-624 — 复用共享读取工具与可编辑的初始 Agent definition + +集中放置方案已由 D-629 撤回;下述 app/agent_tools 是历史提案,不得据此实施。工具复用、exact IDs +与初始 definition 的其它合同保留。 + +Sir 确认 Agent Query 推荐使用 retrieve、get_entities、resolver、get_entity_neighborhood、find_path、 +get_connected_components 六个已有读取工具,加上 D-622 的 submit_query_result。不另建研究、总结工具, +不固定检索次序。六个读取工具及专用输入 schema 从 Organization 迁入普通 app/agent_tools 适配模块, +保留 exact IDs 与单一 AgentManager registry;运行时与 Resolver 不反向依赖该适配层。Organization +写入工具仍归 Organization,结果提交工具归 Agent Query。 + +提供可编辑的推荐初始 definition,创建后是普通 agents 记录,配置者选择模型;不在启动时覆盖修改, +不新增模板 registry。模板的实际分发与配置旅程仍需落实。此次确认不表示现有 JSON 工具结果已经支持 +原始多模态内容;内容交付边界仍待单独复核。本项不授权源码实施。 + +## D-625 — MVP 使用文本与结构化证据,不新增原始媒体工具结果通路 + +Sir 确认本轮消费 Resolver 提供的文本/JSON,以及图中的 OCR、转录、解释等文字内容;图片、音频、 +视频 Block 仍可检索、引用并沿图探索,不限于 text resolver。保留普通 Resolver 读取可能发生的 lazy +materialization,但本轮不新增将原始媒体从工具结果送入模型理解的通路。 + +文字衍生内容并非原始媒体的无损替代,无法取得必要依据时应说明理解边界,不能声称看过未读取的原始 +内容,也不能把未取得当成不存在。本次取舍不修改通用 Agent/AI 多模态消息合同;工具可发现某方法与 +其结果可交付给模型仍须区分。验收应核对媒体文字子图的可达性和实际证据,不把 JSON/base64 当作视觉 +或听觉理解。该取舍已批准,不再作为未决范围;不授权源码实施。 + +## D-626 — 文档交付初始 definition,CLI 补齐普通 Sink 管理 + +Sir 确认使用文档提供可复制、编辑的 Agent definition JSON,通过已有 ai models、agent create/update +选择模型并创建普通 Agent。模板不内置于 CLI,不新增模板 API 或安装向导,不要求本地 checkout Core。 + +本轮 CLI 增加 sink types/list/get/create、config get/replace、enable/disable/delete,投影现有 Sink +管理 REST,动态配置 schema 来自 Core。创建 Sink 时 config.agent 引用 Agent,随后显式在当前接入 +Peer 启用并挂载查询 endpoint。实际调用使用 query --sink,再通过普通 job wait/get/abort 控制与消费。 +已有 Agent/Sink 可以复用;此旅程不扩展为 AIProvider 或整个部署的初始化向导。尚未授权源码实施。 + +## D-627 — Job 终态按执行与交付映射,预算仅由 runtime 控制 + +Sir 确认:Turn 返回 completed 或 max_model_calls 且存在成功提交时,Job 为 finished,state 保留 +result 与原始 termination;两种返回均未提交时为 failed,明确说明未交付。真实执行异常、超时与 +取消仍为 failed/timed_out/aborted,并按 D-622 尽力保留已有结果。不新增通用 Job 状态或 partial、 +has_result 标志,也不把空依据的有效回答当作未交付。 + +Sir 特别要求不让 LLM 检查或知晓运行预算。此处预算判断由 Python Thread runtime 在完整工具批次 +之后执行,不向模型注入剩余次数、倒计时、预算查询工具或强制收尾提示。预算仍是 Agent definition +中的执行参数,供 runtime 使用;最终 termination 是调用者可观察的执行事实,不是返回给模型的反馈。 +本轮初始 definition 模板也不将该预算复制进 system prompt。 + +## D-628 — 四条验收旅程与实际发行消费作为关闭终点 + +Sir 确认 unit acceptance.md 的 A1–A4:独立 CLI 配置至回答、真实材料上的有依据回答、媒体文字子图 +可达性,以及异步控制与实例生命周期。共享工具迁移补一条既有 Organization 实际旅程,防止旧消费者 +回归。手工/显式脚本验收,不新增自动 CI E2E,不固定模型措辞或工具顺序。 + +确认交付终点为 preview 通过后,按另行授权完成合并与发布,观察 Core production 成功、CLI 发布 +PyPI,并以本地安装的已发布版本连接 production 完成查询。当前继续完成 preflight 与 Impact +Handshake;本项不是源码实施或 Git/发布操作授权。 + +## D-629 — 工具能力回归领域 owner,禁止中央 agent_tools 业务模块 + +Sir 否决 app/agent_tools,不仅本 unit 不创建,后续也不得以该目录或换名的中央工具业务模块组织能力。 +泛化是消除 Organization 专用归属并回归真实领域,不是把“可以被 Agent 调用”当成新的业务领域。 +retrieve 归 InfoBaseManager;图邻域、路径、连通性归既有 GraphNavigationRetrievalManager; +submit_query_result 归 AgentQuerySink;Resolver 能力归 Resolver 领域。get_entities 同归 info-base。 +输入 schema 按对应领域归属,不继续集中在 organization_behavior,也不建立中央工具 schema 集合。 +AgentManager 继续拥有注册、输入绑定与执行机制,不因保有 registry 而取得工具业务 ownership。 + +Sir 提出 ResolverBase 作为 resolver 工具的落点。源码中基类名为 Resolver;工具同时包含跨类型发现 +和跨 Block 批量调用,get_method_contracts/invoke_method 已由 ResolverManager 拥有。因此建议保持 +单 Block 行为在 Resolver 实例、发现/分派在 ResolverManager,不把跨实例目录塞进基类。这一具体 +落点是待复核的细化建议,不记作已接受。 + +旧 preflight 的路由、模型、Thread 实验仍有效,但集中模块迁移的依赖结论失效。需重新核对按 owner +拆分后的注册次序、方法/工具输入接合与依赖方向,再刷新 Impact Handshake。未获实施授权。 + +## D-630 — Agent Tool handler 是 controller,领域方法是 service + +Sir 确认 ResolverManager 发现/分派、Resolver 实例执行的分工,同时指出这不构成工具归属的例外: +Agent Tool 早已采用 controller/service 分离。Tool handler 负责工具输入/输出适配及调用领域入口; +Manager/Resolver 等拥有可复用业务行为。runtime 按 Tool schema 进行输入验证,并非 handler 或 +service 重复校验的理由。领域 owner 不等于必须把带 AgentManager.tool 装饰器的函数塞进 Manager。 + +复核已有 durable evidence:business-pipeline-and-authority.md §5/§6 及 Allowed Direction 已说明 +handler 调用领域 owner、Agent runtime graph-blind;graph-navigation-retrieval.md 已将 HTTP/MCP/ +Agent Tool 并列为业务方法调用者。文档并非完全缺失,主代理没有正确应用已有边界是直接错误。 +不过明确的 controller/service 定义及其与领域归属的关系未被集中说明;Agent subtree AGENTS 未链接 +这条结构规则,读取工具描述仍埋在 Organization 章节,容易混淆消费方与能力 owner。 + +本轮在 packet 记录定点文档修正计划:增强既有本地架构 authority、增加最近 AGENTS 的导航引用, +不新建中央工具模块或第二份规范,不把此实现分层升级为新的产品概念。随着实施更新 durable docs; +当前只记录计划。D-629 的 Resolver 具体分工已获确认,不再作为待 Sir 选择的问题。 diff --git a/tasks/knowledge-lifecycle-capabilities/decisions/D631-D640.md b/tasks/knowledge-lifecycle-capabilities/decisions/D631-D640.md new file mode 100644 index 00000000..cca89e34 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/decisions/D631-D640.md @@ -0,0 +1,12 @@ +# Decisions D-631–D-640 + +## D-631 — 本 unit 修正文档 authority,并沉淀长期判断准则 + +Sir 指出 D-630 暴露的问题不只是文档分散或导航缺失:消费者文档可能越权定义依赖能力,这与代码 +ownership 一样是边界错误。要求本 unit 一并修正相关同类问题,并建立长期 durable doc authority +判断准则。不能只添加链接而保留原地的第二份规范,亦不能把任务提出/实现某能力的历史当成永久 owner。 + +检查范围覆盖 Agent Query 直接涉及的 Agent/Tool、InfoBase、Resolver、检索、Organization、Sink +本地文档与相关共享声明;不是全仓安全审计或无界文档重写。准则以单条 claim 的语义 authority 为单位, +区分定义依赖能力与说明消费者如何使用依赖能力。具体计划、证据与建议落点见 +[documentation-authority](../units/agent-query-sink/documentation-authority.md)。当前更新 packet,未进入源码实施。 diff --git a/tasks/knowledge-lifecycle-capabilities/decisions/index.md b/tasks/knowledge-lifecycle-capabilities/decisions/index.md index 4fb5749d..1e85d30f 100644 --- a/tasks/knowledge-lifecycle-capabilities/decisions/index.md +++ b/tasks/knowledge-lifecycle-capabilities/decisions/index.md @@ -67,6 +67,9 @@ so one stable ID has one predictable address;the shard boundary does not imply | [D-581–D-590](D581-D590.md) | CLI 自签 JWT、命名连接、实体/Resolver REST、内容与文件交付、Job / Source / Cron / Agent / Config 管理、AI 与 Tool 发现 | | [D-591–D-600](D591-D600.md) | CLI 校验边界修正、Peer / Extension、检索与 Graph REST、整理 Job、实体修改、独立项目与 PyPI 发布、查询续读 | | [D-601–D-610](D601-D610.md) | CLI 验收确认、Pydantic 校验边界使用指南 → 实现准备 | +| [D-611–D-620](D611-D620.md) | Agent Query Sink 实例、Job-backed REST → 基于 info-base 回答信息需求 | +| [D-621–D-630](D621-D630.md) | Agent Query 回答与引用 → 结果交付设计 | +| [D-631–D-640](D631-D640.md) | Durable doc authority 与本 unit 文档纠正 | | [Withdrawn frames](withdrawn.md) | Explicitly rejected organizing frames and proposals | ## Register Rules @@ -82,10 +85,11 @@ so one stable ID has one predictable address;the shard boundary does not imply ## Current Edge -- Latest confirmed decision by registered ID: [D-603](D601-D610.md)。MCP sink retains D-381–D-420;Telegram extension retains - D-421–D-460;Organization Nowledge study retains D-461–D-570;CLI sink reserves D-571–D-610。 +- Latest confirmed decision by registered ID: [D-631](D631-D640.md)。MCP sink retains D-381–D-420;Telegram extension retains + D-421–D-460;Organization Nowledge study retains D-461–D-570;CLI sink retains D-571–D-610;Agent Query Sink reserves D-611–D-650。 - MCP、Telegram 和 [organization-nowledge-study](../units/organization-nowledge-study/packet.md) 均已关闭;Organization - 已完成 PR #100 / #101 合并及 Core 0.2.0 生产交付。当前 [CLI sink](../units/cli-sink/packet.md) 已完成完整预演,等待 Impact Handshake 后实施; + 已完成 PR #100 / #101 合并及 Core 0.2.0 生产交付。[CLI sink](../units/cli-sink/packet.md) 已正式发布并关闭;当前 + [Agent Query Sink](../units/agent-query-sink/packet.md) 已进入 Implementation / local acceptance,等待 draft PR preview 的真实模型验收; Parent task 保留其余候选与 durable-owner reconciliation。 - Parallel placement and integration surfaces are shared peer control in the [roster](../collaboration/roster.md);there is no coordinator role。 diff --git a/tasks/knowledge-lifecycle-capabilities/documentation-promotion/index.md b/tasks/knowledge-lifecycle-capabilities/documentation-promotion/index.md index a21b2e60..807cba20 100644 --- a/tasks/knowledge-lifecycle-capabilities/documentation-promotion/index.md +++ b/tasks/knowledge-lifecycle-capabilities/documentation-promotion/index.md @@ -2,6 +2,9 @@ ## Navigation +- [Agent Query 文档 authority 纠正](../units/agent-query-sink/documentation-authority.md):D-631 纳入本 unit, + 包括消费者越权定义、旧合同副本与长期判断准则;尚未应用,新增 Hub meta 影响面。 + - [Known corrections](known-corrections.md) - [Candidate Hub PRD batch](hub-prd.md) - [Candidate Hub Product TDD batch](hub-product-tdd.md) diff --git a/tasks/knowledge-lifecycle-capabilities/documentation-promotion/spoke-unit-tdd.md b/tasks/knowledge-lifecycle-capabilities/documentation-promotion/spoke-unit-tdd.md index 407307ea..14fc5dcf 100644 --- a/tasks/knowledge-lifecycle-capabilities/documentation-promotion/spoke-unit-tdd.md +++ b/tasks/knowledge-lifecycle-capabilities/documentation-promotion/spoke-unit-tdd.md @@ -13,3 +13,15 @@ core-py local Unit TDD promotion 已应用,只记录本仓内部 implementatio 具体 ownership 已投影到 `docs/30-unit-tdd/memos-extension.md` 与更新后的 `business-pipeline-and-authority.md`;临时 implementation observation 未被提升为共享合同。 +## Agent Query:待实施时修正的 Agent Tool 分层说明 + +D-629/D-630,尚未应用。Owner 是既有 `docs/30-unit-tdd/business-pipeline-and-authority.md`,不是新文档 +或 Hub 产品声明。该文档已要求 Tool handler 调用领域 owner,但 §6 将六个通用读取工具置于 +Organization 章节,且未明确工具 controller / 领域 service 两个维度。 + +实施时在 §5 明确 `Agent runtime → 领域所属 Tool controller → 领域 service`,说明输入校验、结果 +投影与业务效果的边界;共享读取能力从 Organization 消费语境移到其实际 owner 的说明,§6 只引用。 +补一个 resolver controller 调用 ResolverManager、再调用 Resolver 实例的最小例子,不复制 schema 清单。 +`app/business/agent/AGENTS.md` 添加指向该 authority 的导航,强调修改工具归属时先读;必要时更新 +Unit TDD 索引的检索描述,不另建一套规则。验证是检查实际注册 handler → service 的调用链与文档一致, +并核对领域 service 不为 Agent 输出格式而丢失自身返回能力。既有测试策略不变。 diff --git a/tasks/knowledge-lifecycle-capabilities/packet.md b/tasks/knowledge-lifecycle-capabilities/packet.md index 6e1974b3..ef30157f 100644 --- a/tasks/knowledge-lifecycle-capabilities/packet.md +++ b/tasks/knowledge-lifecycle-capabilities/packet.md @@ -18,10 +18,9 @@ 能力划分见 [capability-map.md](capability-map.md),当前状态见本页下方;details stay in each unit packet and the [decision register](decisions/index.md)。GitHub extension 的 collection-side correction remains queued, but no longer blocks root-usability selection after ownership corrections merged。 -- **Next Step**: [CLI sink / inkcre-cli](units/cli-sink/packet.md) 已于 2026-09-14 关闭。Sir 授权后按依赖 - 合并并正式发布;PyPI 0.1.0 本地安装连接生产 Core 的完整复验通过,见 - [交付](units/cli-sink/delivery.md)与[生产验收](units/cli-sink/production-acceptance.md)。 - Parent task 保持 active,等待 Sir 选择下一 unit,不自动扩展范围。 +- **Next Step**: [Agent Query Sink](units/agent-query-sink/packet.md) 已实现并通过 Preview 真实模型 A1–A4; + Sir 对 #111 的维护性 review 已落实,本轮冷启动/路由 smoke 与本地门禁通过。继续 review Core PR #111 与 + Hub PR #29,不合并。 ## Program Boundary @@ -43,17 +42,12 @@ ## 当前 Unit 的入场 -2026-09-13,Sir 选择 `cli-sink`:Python 独立子项目 `inkcre-cli`,通过 pip 分发,作为操作 Core 的命令行界面。 -本轮只支持 Core REST API;它不直接访问数据库,也不参与 Peer delegation。见 [D-571](decisions/D571-D580.md)。 +2026-09-20,Sir 选择 `agent-query-sink`,由 AI 组合 info-base 原始查询,服务检索而非下游创作;见 +[D-611](decisions/D611-D620.md)。已从干净、与 origin/main 一致的 `676886a` 切出 `feat/agent-query-sink`。 +当前已完成实现与 Preview 验收,Sir 对 #111 的维护性 review 已落实(`daab9d6`),继续等待复审。 +环境入口仍为 `AGENTS.local.md` 与 `svc.local.json`。 -本次交接的已发布基线是 Core 0.2.0 / main `b3ccb00`;新 session 仍需检查当时的最新 main。CLI 的设计与完整 -preflight 已整理为独立提交;前一单元的本地收尾仍未提交。新 worktree 不会自动带上未提交记录,应在建分支前 -核对并保留或显式转交所需 packet,不能把旧提交里的 active 状态当作现状。环境入口是 `AGENTS.local.md` 与 -`svc.local.json`,不要复制凭据或以本机没有 Docker/PostgreSQL 推断数据库不可用。 - -当前最新决策为 D-603;CLI 已获合并授权,正式发布与生产验收完成,unit 关闭。 -Organization 保留 D-461–D-570,CLI 保留 D-571–D-610,不复用历史空号。 -CLI 已在 root worktree 从与 origin/main 一致的 `b3ccb00` 切出 `feat/inkcre-cli`,保留原有未提交 task-control。 +Organization 保留 D-461–D-570,CLI 保留 D-571–D-610,新 unit 保留 D-611–D-650,不复用历史空号。 已关闭 session 不再持有源码锁,历史授权和 deferred 项也不自动成为新 unit 的实施范围。 [Organization 的 Hub 待提升项](documentation-promotion/organization.md)、语义误判与已知 SQL 性能残余继续保留, @@ -62,6 +56,10 @@ CLI 已在 root worktree 从与 origin/main 一致的 `b3ccb00` 切出 `feat/ink ## Unit 状态与选择 +[Agent Query Sink](units/agent-query-sink/packet.md) 为当前 active unit。D-611–D-631 的合同、工具归属、 +controller/service 与文档 authority 修正均已实现;Core PR #111 的 Preview 真实模型 A1–A4 与修正后镜像 smoke +均已通过。合并、发布与 production 复验尚未授权。 + [CLI sink](units/cli-sink/packet.md) 已关闭,公开接口、实现、四条本地旅程、跨 owner 正式交付及 PyPI 安装 连接生产 Core 的复验均通过。研究依据包括本任务 Agent Tool 模式、xiaoland/svc 的 CLI 实践及一手公开材料。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/acceptance.md b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/acceptance.md new file mode 100644 index 00000000..dcc107ce --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/acceptance.md @@ -0,0 +1,72 @@ +# 验收方案(D-628 已确认) + +依据 D-611–D-628。验收证明配置、异步交付、基于材料回答与实际消费;不以工具数量、固定调用次序、 +固定措辞或单次模型正常结束代替产品价值。本文保留已确认的验收合同;Preview 执行结果见 +[implementation-evidence](implementation-evidence.md),production/PyPI 仍待后续授权。 + +## 数据与证据 + +使用少量有出处、可人工通读的软件工程材料,覆盖事实查找、跨材料比较与有依据的解释。优先复用已有 +SQLite 官方 Architecture 快照,并选择真实 InKCre 设计文档中的相关材料;具体清单、版本、问题及 +预期依据在 preflight 固定。旧 semantic corpus 混有人工编写的 memo/feed 条目,不能全称为真实文章。 +不把 task decision 编号或验收 alias 填进产品内容以提示答案;运行记录单独映射实际 Block/Relation ID。 + +每个问题先人工确定必要依据、允许推论和不可得结论,再运行推荐 Agent。正文、引用实体、实际读取 +内容和模型轨迹一起复核:引用可打开且支撑附近判断,不能仅主题相似。原文有的事实与推论需分清, +但不要求逐句套标记模板。保留同主题干扰材料,避免只有唯一相关 Block 的示范替代检索验收。 + +媒体材料复用既有真实媒体准备路径及其已生成的 OCR/转录/解释文本;核验实际文字内容和父子关系, +不将虚构字幕称为转录。Query 本身不承担预先准备索引/解释的职责。必要 preparation 另记入证据。 + +## 四条纵向旅程 + +### A1:独立 CLI 从配置到收到回答 + +在隔离 Python 环境安装本次构建的 CLI wheel,不安装或 import Core。连接 PR preview,通过文档模板 +创建 Agent,使用新 sink CLI 创建并启用 Sink,发现动态输入 schema,再发起 query。验证请求按 +202/Job/Location 返回,CLI 复用 job wait/get 获得 answer/references;使用相应 get/resolver 命令 +打开依据。不把命令成功退出等同于 Job 成功;`--json` 保持完整 compact JSON,长人类输出可取完整文件。 + +### A2:找到材料,并基于材料回答 + +至少包括:精确线索找原文、跨两份材料比较前提/理由、从一个已知实体追溯相关依据,以及证据不足的 +问题。Agent 可以选择不同查询组合,不要求每项工具都调用;但整体证据需证明不是只依据模型常识作答。 +比较/解释应超越简单命中列表,又不能编造 graph 中未存在的关系。证据不足可以有效交付,不能声称 +已经穷尽整个 info-base。关键查询换一种自然问法复核,不以重跑挑选最好一条掩盖错误。 + +### A3:媒体文字内容可达 + +从媒体 Block 出发,Agent 能通过关系找到并读取已有转录/OCR/解释正文,引用支撑回答的实体;不能 +只读 title/MIME 等元数据后假装看过媒体。选择一项需要这些正文才能回答的问题,并观察缺少文字依据时 +是否准确说明能力边界。不要求 Query 调用原始多模态模型,也不把解释当作未经推断的原始事实。 + +### A4:异步控制和实例生命周期 + +有界 wait 返回仍运行的 Job 后,可以继续等待;结束观察不取消 Job。实际 abort、wall-clock timeout +沿已有 Job 状态收尾,已提交回答按 D-622 尽力保留。验证 Sink enable → disable → enable 后 endpoint +及运行时 schema 随之变化,两个实例之一撤下不影响另一个的发现/调用;旧 Job 仍可查询,已执行 Job +不因 disable 被隐式取消。通用 Job API 也能 +受理同类型任务;不为了此旅程重做多 Peer 调度或模拟大规模竞争。 + +用隔离的临时 Agent definition 做 D-627 的边界压力:最后允许的一次调用提交结果、预算结束却未提交、 +正常结束未提交,以及已有提交后发生执行失败。模型未产生目标轨迹时不能宣称覆盖;必要时用独立脚本 +替换最小模型响应边界以确定重现,该证据仅证明状态映射,不计作真实模型回答质量。不给产品添加测试模式。 + +## 执行与通过标准 + +以手工或显式脚本执行,不新增自动 CI E2E、工具/schema 单元测试或安全审计。复用既有静态检查、 +admitted checks 与数据准备能力,但不导入旧测试的数据库重置流程操作共享 preview。 + +每条旅程记录 CLI/Core SHA 或版本、模型/definition、语料身份、实际 Job/实体 ID、结果与残余。语义 +通过需有明确依据,不采用任意分数线或“有引用即成功”。针对明确失败修正后记录干预与重跑结果。 +共享读取工具迁移还需至少一条既有 Organization 实际读取/写入旅程作为回归,证明没有丢失注册或错误 +地改变工具合同,并核对冷启动下 exact IDs 与关键输入 schema 没有因导入副作用而丢失;这不是重新 +验收全部 Organization approaches。 + +D-629–D-631 增补结构与文档核验,不改变四条产品旅程:检查 Tool controller → 领域 service 的 +调用方向、exact IDs/输入合同和普通领域返回能力;按 documentation-authority.md 核对合同唯一 owner、 +旧位置清理及引用可达。此项通过源码/文档审查与既有静态检查完成,不增加自动化测试。 + +已确认交付终点:preview 四旅程通过 → 经单独授权合并与发布 → production 部署成功、CLI 发布 PyPI → +本地隔离安装已发布版本并连接 production 完成配置/一次有依据查询。此终点不代表当前 +已获提交、推送、合并或发布授权。Core 先就绪,再验证 CLI 消费;不重做现有 release workflow。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/corpus.md b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/corpus.md new file mode 100644 index 00000000..d2acb3d5 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/corpus.md @@ -0,0 +1,37 @@ +# 验收材料与人工判据 + +本页固定 D-628 的小规模软件工程 corpus。它是验收依据,不是产品 prompt、schema 或检索特殊处理。 +新 Query 尚未实现,因此这里没有查询成功记录。加载后在执行证据中记录真实 ID;下面文件名不变为产品 alias。 + +## 版本与材料 + +| 材料 | 版本/来源 | SHA-256 | +| --- | --- | --- | +| SQLite Architecture | 已有 tests/semantic_retrieval/acceptance/corpus/sqlite-architecture.html;官方 https://www.sqlite.org/arch.html 的 2026-08-07 快照 | e5e00e53255dc1093fc8c087ff31b84c93dc802366392bda14e3f085776f521a | +| Graph Navigation Retrieval | 本仓库 676886a 的 docs/30-unit-tdd/graph-navigation-retrieval.md | 9e248d02cdf87d604fd62052aca98dafbfec735b11880fd6279df1380f1eb93d | +| Lexical Retrieval | 本仓库 676886a 的 docs/30-unit-tdd/lexical-retrieval.md | a162f6dfb153871a5133bd6f729f61df11f3e00569af829e8bafa6e518b68bbf | +| NASA GPM / Dave McComas 视频与作者字幕 | 既有 prepare_media_assets.py 的 origin URLs;.assets 文件仍在本机,两个原始 digest 已核对 | 视频 03168dd86fe492fed362cb64d5ce3d29989b8573cd9dfdaebfed0e11512f7427;字幕 69d12ca3151641496096e074b81749ad4f3fb173fcc08b56ce012dd1eddcc40d | + +文档以固定版本所说的内容为 authority,不把文档自动当作当前运行源码事实。文档之间可互为同主题干扰项; +不使用旧人工撰写的“deep modules”feed 条目冒充外部真实文章。SQLite 保留 HTML,经 HTMLResolver +读取;技术文档以真实 text/markdown 内容入库,不给 query 注入答案摘要。 + +NASA 原始媒体与派生文件继续留在 ignored assets;以现有 Storage/Resolver 路径准备 media → subtitle/ +transcript 等子图。优先使用作者字幕验证精确事实;若使用模型生成转录/解释,标明来源并人工对照, +不以模型输出给另一模型自证。数据准备与索引维护在 Query 外进行,保留其实际运行结果。 + +## 查询与预期依据 + +| 问题意图 | 必要依据 | 不应推导出的结论 | +| --- | --- | --- | +| SQLite 谁处理 page cache、locking、rollback/commit;B-tree 与它怎样分工? | Architecture 的 B-Tree / Page Cache 段,pager.c 与 page-cache 职责 | 不能把 SQL parser 说成事务实现 owner;不凭常识编造快照未写的算法细节 | +| 根据这两份 InKCre 检索文档,图导航与词法查询怎样互补? | 图导航遍历已有关系;lexical 找 Block-local 文本线索;子图文字独立索引 | 图导航不是 Resolver 解释,也不自动补齐未建模关系;lexical 不递归复制所有子块文字 | +| 已知这份媒体中的人物:他为何说软件实验室仿真并不能完成所有验证? | 视频字幕约 32–52 秒:仿真可验证范围、实际 spacecraft/flat sat、交付后性能测试 | 不能把具体项目说明扩成“所有仿真都不可信”;不能仅凭视频 title 回答 | +| 从给定媒体 Block 找到支撑上述回答的文字依据 | 实际 media → subtitle/transcript 关系及其正文;引用应能重新打开 | 未观察到关系不能臆造;Query 不因问题而重写 graph | +| 这些材料能否给出原始图片中某处颜色,或哪项设计在所有场景都性能最优? | 材料缺少必要依据/本轮不能查看原始媒体 | 必须区分未能理解与不存在;不得用无引用常识作确定结论 | + +复述查询时不增加指向答案的工具顺序提示。评审看答案是否回应问题、引用是否对应证据、推论有无 +越界;不要求句子相同、引用顺序相同、固定最短图路径或命中固定数据库 ID。 + +这是最小验收集,不宣称覆盖全部 retrieval 难度。若轨迹只用标题即可命中全部答案,应增强真实材料 +干扰/问题,而不是让实现识别 corpus。预算值与 prompt 的调整要记录原轨迹和改动原因。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/documentation-authority.md b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/documentation-authority.md new file mode 100644 index 00000000..62d3720e --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/documentation-authority.md @@ -0,0 +1,50 @@ +# 文档 authority 修正 + +D-631 将本项纳入 agent-query-sink;目前为实施计划,尚未修改 durable docs。 + +## 已确认的长期判断准则 + +以单条声明而非整篇文件或物理目录判定 authority,先问“这句话在定义谁的合同”。 + +1. **语义 owner**:谁负责决定该行为及其变化,谁的规范文档拥有它。发现需求的消费者、首次交付的 + unit、代码当前位置均不能自动成为 owner。跨领域依赖方向由架构文档定义,领域具体行为由领域文档定义。 +2. **声明范围**:产品意图归 PRD;跨实现必须一致的合同归共享 TDD;本实现的机制归 Spoke TDD; + 操作步骤归运行/开发文档。重要或多次使用不自动意味着应该提升到 Hub。 +3. **定义与消费**:消费者可说明自己选用什么、如何组合及自己的约束,但不得替依赖定义可用能力、 + 生命周期或失败语义。保留理解消费场景所需的简短摘要与引用,不维护一份可独立修改的依赖规范。 +4. **变化传播**:若该能力的合同改变,是否必须在多个消费者文档中重新决定同一事实?若是,则很可能 + 有多个 authority。owner 修改合同,消费者仅调整真正受影响的集成事实;不是要求依赖变化永不影响消费者。 +5. **证据与状态**:现行合同、观察到的实现、历史方案、示例分别标明。代码用于核实实际行为,不能仅凭 + 代码与文档不一致就自动改写产品要求;先判断是实现缺陷还是文档过期,纠正后留下一个现行规范来源。 + +文档不是简单按“Hub 高于 Spoke”覆盖:Hub 无权因存放位置而拥有本地机制,消费者文档也无权覆盖领域 +合同。冲突按声明的语义 owner 与适用范围解决。AGENTS 负责导航及局部重复风险,不复制整份架构合同。 + +## 已发现的同类问题与预期修正 + +| 文档/声明 | 问题 | From → To | +| --- | --- | --- | +| business-pipeline-and-authority §6 的通用读取工具合同 | Organization 是消费者,但其章节承载通用能力与工具接合规则 | 跨领域 controller/service 规则放架构层;具体能力归相应领域说明;Organization 只说明组合用途 | +| organization.md 的 Reading and Agent boundary | 混合行为自己的工具选择与通用实体/Resolver 结果合同 | 保留行为选用与写入约束;通用工具 schema/结果规则引用相应 owner | +| organization.md 的 Graph use 首段 | get_connected_components 的通用返回/截断合同与 duplicates 用法混写 | 查询合同归 graph-navigation-retrieval.md;Organization 保留如何解释 provenance occurrence | +| semantic-retrieval.md 的 Rumination And Agent Boundary | 仍规定整理触发/Agent runtime;“无 periodic trigger”与现有 Organization automatic Jobs 表述冲突 | 删除独立定义;保留必要的检索/整理关系与引用,核实触发的现行 owner 合同 | +| Agent subtree AGENTS / TDD 索引 | 无明确导航到工具 controller/service authority | 补精确引用,不在多处复制规范 | + +还需按上述准则检查直接相关的 Sink/MCP、Resolver、检索文档及共享声明,记录发现而非预判全都有问题。 +单纯提及其它领域或举例并不是越界。历史验收材料可以保留当时背景,不伪装成当前能力规范。 + +## 准则与具体合同的归属 + +将 InKCre 文档 authority 准则补入 Hub 现有 `00-meta/submodule-profile.md`,扩展其现有 ownership +部分,适用于同一 repo 内的领域边界及 Hub/Spoke 边界;不放 PRD,也不另建文档 framework。shared-doc +skill 引用该准则,避免复制为第二份规则。具体 Python Agent Tool controller/service 合同仍归 Core +`business-pipeline-and-authority.md`,不能因本次重要而提升为 Hub 实现规范。 + +这增加本 unit 的 Hub 文档影响面,旧 Handshake 的“无 Hub 修改”已失效。实施时遵循 Hub source 先改、 +经授权提交推送后 Spoke 单独 bump ref;禁止编辑挂载目录。当前仅做只读调查及 packet 更新。 + +## 验证 + +逐项核对旧位置是否还在独立规定已迁走的合同、新 owner 是否完整、消费者链接是否可达、实际代码是否 +符合现行合同。以 Resolver tool controller → ResolverManager → Resolver 实例为一个阅读路径,但同时 +核对 retrieve 与图查询,避免只为单例修文档。无需新增文档 gate、全局 claim registry 或自动化测试。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/impact-handshake.md b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/impact-handshake.md new file mode 100644 index 00000000..306f3f25 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/impact-handshake.md @@ -0,0 +1,52 @@ +# Impact Handshake + +本文是实施授权前的历史快照。此后 Sir 已授权实现、验收、提交、推送与 draft PR,仍未授权合并。 +实际实现与 Preview 证据见 [implementation-evidence](implementation-evidence.md);当前授权和阶段以 +[packet](packet.md) 为准,下文保留当时的复核内容与授权状态。 + +2026-09-20。设计依据更新至 D-631,D-628 的四条产品验收旅程保留,关键 preflight 与设计修正核对已完成。 +Sir 已授权本次 task packet 提交;尚未授权源码实施、推送或发布。 + +D-629/D-630 的领域归属与 controller/service 修正已反映于 P1;D-631 的文档 authority 纠正及 Hub meta +判断准则已反映于 P4,见 documentation-authority.md。未实现的新路径仍须按计划验收。 + +## 对象与变化 + +| 对象 | From → To | 影响与约束 | +| --- | --- | --- | +| 读取能力归属 | Organization 内六个读取工具与输入模型 → InfoBase、GraphNavigation、Resolver 各自领域 owner | 禁止 app/agent_tools;exact IDs 不变;Tool controller 调用领域 service,ResolverManager 分派至实例;不将 handler 整体塞进业务类 | +| SinkBase / SinkManager | 强制 hooks、可交错的实例管理 → hooks 默认 no-op、同实例管理操作串行 | 已复现 enable/disable 竞态;配置更新、删除与关闭使用同一管理边界。影响现有 MCP Sink,必须回归;运行 Job 不持锁 | +| Agent Query | 无实现 → AgentQuerySink、类型级 JobHandler、普通 submit_query_result 工具 | sink.config 引用既有 Agent;复用 Thread 与 Job,不建立新 runtime、持久会话或恢复机制 | +| REST / schema | 无 Query endpoint → 启用实例挂载 POST /sinks/{id}/query | 普通 REST JWT 依赖;202 + Job + Location;关闭撤下自己实际注册的 route 对象并刷新 OpenAPI;不是 Peer delegation | +| CLI | 无 Sink 管理与 Agent Query → sink 管理组、query 命令 | 只消费 REST,不 import Core;动态 schema 查具体实例路径,后续控制复用 job get/wait/abort | +| 数据与发行 | 既有 Sink/Job/Agent 表及 Core/CLI 发行机制 → 增加 type/catalog 声明与两个项目的 fragments | 无新表、列、schema migration或依赖;不 seed 实例、Agent;不改版本/发布编排 | +| 文档 | 旧工具 owner、消费者越权声明与缺失使用旅程 → 领域 authority、操作模板、CLI 文档与 Hub meta 判断准则 | Hub source 单独修改/发布后 Spoke bump;不修改挂载 docs/_shared;不改 client-web/ext-reg | + +## 保持不变的合同 + +Query 读取 info-base,不为回答写 graph;结果是 answer + references。最后成功提交以闭合 Thread +历史为依据,Job 收尾尽力保存。正常返回有结果才 finished;取消、超时或异常保留真实 Job 状态。 +LLM 不接收 runtime 预算计数或临时收尾消息。禁用 Sink 不取消已运行的 Job。 + +模型读取以文本/JSON 和已有媒体文字子图为限;不把 metadata 当正文,也不新增原始媒体 ToolResult +通路。输入在真实输入边界验证,不重复验证可信工具输出。结果保存不是实时或崩溃恢复保证。 + +## 执行与验证 + +按 [实现计划](implementation-plan.md) 的 P1–P5 推进:先移动共享工具,再实现 Sink/Job,接合动态 +REST/CLI,完成文档与机械检查,最后跑 [四条验收旅程](acceptance.md)。共享工具迁移须验证旧 +Organization 读取/写入;生命周期修正须验证 MCP 启停,不将隔离实验升级为自动化测试。 + +[Preflight 实测](preflight-evidence.md) 已覆盖动态 FastAPI 接合、真实 Thread 的六条路径、Sink 竞态、 +真实模型工具调用、六工具完整 schema、catalog 收敛路径与静态基线。它不替代新 handler、持久化 +收尾、CLI 和实际回答质量的端到端验收。 + +## 残余与授权边界 + +production 当前没有 AIModel,正式验收需通过已有管理接口配置模型及检索环境。新功能尚未实现, +所以 preview artifact 与最终 production/PyPI 验收尚不存在;这是实施后的交付阶段,不是已完成证据。 +超大内容仍可能超过模型上下文;取消未闭合工具 batch、进程崩溃或数据库收尾失败仍可丢失结果。 +不为这些已接受的上限引入隐藏截断、自动重试、恢复框架或额外索引。 + +等待 Sir 明确授权开始实施;后续 Git 操作与发布另行确认。本次获授权提交的内容仅为 task packet, +不含忽略目录内的隔离实验、开发环境产物或任何生产源码;未推送或写入 production。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/implementation-evidence.md b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/implementation-evidence.md new file mode 100644 index 00000000..d8ab582c --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/implementation-evidence.md @@ -0,0 +1,111 @@ +# 实现与验收记录 + +2026-09-20,branch `feat/agent-query-sink`。本页只记录本 unit 的易变交付证据;稳定合同归 +`docs/30-unit-tdd/` 与代码。 + +## 已实现 + +- 通用读取 Tool controller 已从 Organization 迁回 info-base、Resolver 与 Graph Navigation;exact IDs 保持不变, + composition root 显式注册 Core tools。Agent registry 只拥有绑定与执行机制。 +- `core.agent-query.v1`、`core.sink.agent-query.v1` 与 `submit_query_result` 已实现;实例 endpoint 在 enable 时 + 挂载、disable 时按 route identity 撤下。Job state 保存回答、引用和普通结束原因。 +- Sink lifecycle 对同一实例串行化;无 active resource 的 SinkBase hooks 默认为 no-op。 +- REST 增加单个 Sink type 读取,CLI 增加 Sink 管理与 `query`。 +- durable docs 已按语义 owner 纠正;长期 owner 判断准则先写入 Hub PR,再单独更新 shared ref。 + +## 已通过的本地证据 + +- `pdm run check:foundation`;`pdm run check`(14 passed,60 skipped)。 +- `pdm run -p cli check`;`pdm build -p cli` 生成 sdist 与 wheel,产物未入 Git。 +- SVC development runtime `readyz`、migration、roles 与 catalog 就绪,source fingerprint 对应当前实现。 +- 冷启动 Agent Tool discovery 包含既有 exact IDs 和 `submit_query_result`;Sink/Job catalog 分别包含 + `core.agent-query.v1` / `core.sink.agent-query.v1`。 +- 通过构建 wheel 的 CLI 创建并启用临时 Sink,发现动态 query schema,创建 Job,观察并 abort;disable 后 exact + route 返回 404,随后删除实例。临时 Agent ID 不存在,因此这条旅程只证明 REST/CLI/Job/lifecycle,不声称回答质量。 + +## Preview 与真实模型 + +Core draft PR #111 的 Preview 在实现 SHA `b384709` 上通过 delivery checks,Core `/readyz` 报告 migration、catalog、 +roles 与 privileges 就绪。验收使用隔离 virtualenv 安装本次构建的 CLI wheel,只通过 REST 连接 Preview。临时 +AI provider/model 使用 `core.alibaba-model-studio.v1` 与 `qwen3.5-omni-flash`;Agent 1、Sink 1、Block 1–5 和 +Relations 1–2 均为本轮 Preview 数据。 + +- **A1/A2**:Job 2 比较 Block 2 的 Graph Navigation 与 Block 3 的 Lexical Retrieval,交付有依据的互补边界和 + 两个真实引用;CLI bounded wait 先返回 running,随后取得 finished 结果,并可用 get/resolver 重新打开依据。 + Job 4 从 SQLite 官方 Architecture Block 1 准确说明 Page Cache 与 B-tree 分工。Job 12 对材料不能证明的 + “所有场景最优”结论明确回答证据不足并引用 Block 2/3。 +- **A3**:PostgreSQL binary-backed NASA 视频 Block 4 通过 `subtitle` Relation 连接作者 VTT Block 5;Job 8 从 + 媒体实体出发找到并读取字幕,准确说明软件仿真受其覆盖范围限制,实物 spacecraft/flat-sat 才能继续验证。 +- **A4**:Job 13 在 running 时收到 abort request 并结束为 aborted;Job 14 以一秒 wall-clock budget 结束为 + timed_out。第二个 Sink 2 同时启用后,两条动态 schema 均可发现;撤下 Sink 1 时仅其 route 消失,Sink 2 + 继续工作;重新启用 Sink 1 后 route 恢复,旧 Jobs 仍可分页查询。通用 Job API 创建的 Job 15 也被同一 + handler 受理并按一秒预算结束。 +- **Organization 回归**:临时 Agent 2 驱动 explicit rumination Job 16。真实 trace 先调用迁移后的 + `get_entities` 读取 focal Block 1,再调用既有 draft schema/draft/submit 链路,写出 Block 6 与 Relation 3; + Job finished。临时 Organization config 与 Agent 随后删除。 +- **D-627**:本地 preflight 已确定最后一调用提交、提交后异常与取消的状态映射;Preview 又真实覆盖 + completed/max-model-calls 未提交、max-model-calls 已提交、abort 与 timeout。一次 provider 429 被记录为外部 + quota 波动,不改写为产品状态。 + +真实模型也证明 `tool_choice=auto` 可能直接结束而不提交;推荐模板因此改为 `required`。当前 Alibaba adapter 在 +该模式下可能持续再次提交直到预算结束,D-622 的 last-successful-result 合同可保持正确结果,但存在额外模型调用 +成本;本 unit 不为单一 provider 添加 runtime 特例。 + +## 最终镜像复验 + +修正提交 `2dbbfce` 的 repository、portable database、CLI 与 Preview deploy checks 全部通过;最终代码镜像 +`/readyz`、migration `d41cc84db0c5`、catalog、roles、privileges 与动态 query schema 均就绪。Job 17 未在预算内 +提交结果,按合同明确 failed;缩小已知材料后的 Job 18 读取 Block 6、交付准确回答与真实引用并 finished,证明 +新镜像的完整受理、执行和结果链路。`f8fb1af` 只更新 evidence;后续 review 修正单独记录如下。 + +## PR #111 维护性 review 修正 + +Sir 提供的五条 finding 均有依据,处理范围保持在当前 unit: + +1. 恢复 Resolver schema 的说明并区分 `method_schema` 与 `provider_schema`。未采用 `validation_schema` + 命名,因为动态模型只覆盖公开 schema;runtime 先验证 dispatch envelope,方法参数再由 Resolver owner + 逐调用验证。以旧实现生成 schema 对照,新旧完全一致。 +2. 独立进程已复现:仅调用 `register_core_agent_tools()` 时缺少 `submit_query_result`。现在显式导入 + Sink delivery controller;保留 decorator 注册与 Python import cache,不增加另一套 Sink/Job 注册框架。 + 冷启动得到完整 17 个工具,重复 bootstrap 不改变 registry。 +3. `project_json()` 的 bytes 错误改为通用 Agent Tool JSON 表示边界,不再误称为 Resolver 错误。 +4. 为实际 route ownership、无 await 的挂载区间和 OpenAPI 缓存补充注释。当前 FastAPI 0.139.2 创建 + included-router wrapper,并非复制 `router.routes`。MCP 则直接持有自己的 Mount;两者保留各自小型实现, + 目前不抽 DynamicRoutes。两实例 HTTP/ASGI smoke 覆盖独立关闭、重启、404/422 与 schema 更新,无 DB/model I/O。 +5. 技术设计、实施计划、Impact Handshake 和 preflight 标明历史快照;验收合同链接到执行结果,parent + packet 移除“当前只调查”的过期状态。稳定合同仍在 Unit TDD,当前阶段仍由 packet 持有。 + +另为 `_last_query_result()` 写明 Thread 原子追加相邻 Assistant/ToolResult batch 及 ToolCall 顺序的依赖; +后续失败提交不会覆盖前面的成功结果。没有改变结果选择算法。 + +Job schema duplication 保留为架构观察项:`AgentQueryJobParameters.model_json_schema()` 与 hermetic +`BUILTIN_JOB_TYPES_BY_ID` 当前严格一致,但手写 profile 与 runtime model 仍需同步维护。未来若治理整个 +profile,应在 database-contract owner 内解决生成/派生关系;本次不引入新的生成框架或重复 schema gate。 + +本轮 `pdm run check` 通过(14 passed、60 skipped)。以下为可手动重跑的冷启动检查;必须从 repository root +启动新进程,不提前 import Sink 或 REST routes。它不连接数据库,也未纳入自动化测试: + +```bash +pdm run python - <<'PY' +import os +import sys +os.environ['DATABASE_URL'] = 'postgresql+psycopg://review:review@127.0.0.1:1/review' +os.environ['JWT_SECRET'] = 'local-review-only-not-a-deployment-secret' +from app.business.agent import AgentManager, register_core_agent_tools +assert 'app.business.sink.agent_query' not in sys.modules +register_core_agent_tools() +first = AgentManager.list_tools()[0] +assert { + 'retrieve', 'get_entities', 'resolver', 'get_entity_neighborhood', + 'find_path', 'get_connected_components', 'submit_query_result', +} <= {tool['id'] for tool in first} +register_core_agent_tools() +assert AgentManager.list_tools()[0] == first +print('Core Agent Tool bootstrap passed') +PY +``` + +## 尚待 + +- Hub PR #29、Core PR #111 的合并、版本准备、production 与 PyPI 复验均需要后续明确授权;draft PR 不能写成 + unit 已关闭。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/implementation-plan.md b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/implementation-plan.md new file mode 100644 index 00000000..a4e68f3f --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/implementation-plan.md @@ -0,0 +1,118 @@ +# 实现计划(实施前历史基线) + +P1–P4 与 P5 的 Preview 部分已由 [core-py#111](https://github.com/InKCre/core-py/pull/111) 实现并验收。 +本文保留计划快照;当前状态与剩余授权边界见 [packet](packet.md),执行证据见 +[implementation-evidence](implementation-evidence.md),当前实现合同见 +[Agent Query Sink TDD](../../../../docs/30-unit-tdd/agent-query-sink.md)。 + +依据 D-611–D-631 与已确认的 [验收方案](acceptance.md)。领域 owner、controller/service 和文档 authority +修正已纳入下列步骤;注册机制的补充源码核对见 preflight-evidence.md。影响边界见 +[Impact Handshake](impact-handshake.md)。 +当前 branch 为 feat/agent-query-sink,起点 676886a;实施前重新核对 main 与 working tree,不清除 parent packet。 + +## 依赖次序 + +```text +P0 关键预演与设计修正核对(已完成) + → P1 读取能力回归领域 + Tool controller / schema 接合 + → P2 AgentQuerySink + Job + 结果交付 + catalog + → P3 实例 route、运行时 schema 与 CLI + → P4 文档 authority 纠正、Hub 准则、release fragments 与机械检查 + → P5 preview 黑盒验收 → 获授权后发布与 production 消费验证 +``` + +这是工作依赖,不另切 unit;不要求每个文件串行完成,也不授权跨 session 通信。预计只改 core-py 内 +Core 与 cli 两个项目,不新增依赖、不改 client-web/ext-reg。D-631 增加 Hub meta 文档 authority 准则, +按 Hub-first 流程分离 owner 和提交,不为了新功能宣传而写入所有 Hub capability 文档。 + +## P0:已完成关键预演与验收准备 + +见 [preflight](preflight.md) 与 [实测记录](preflight-evidence.md)。动态 route/schema、读取工具依赖、 +取消与结果收尾、catalog 收敛路径已核对;真实 corpus 与目标模型协议握手已准备完成。 +新功能 preview 要在实施后生成,不能将现有基线环境 ready 等同于新功能验收通过。 +D-630 保留普通函数 Tool controller,因而不需要让注册器支持 classmethod 或改变单输入模型约定。 +源码核对已确认现有注册机制适用;移动后的实际冷启动/import 路径必须在 P1 验证,不能在文件尚未 +移动时声称已跑过。D-631 的文档 owner 清单与变化传播检查见 documentation-authority.md。 + +## P1:移动已有能力,不复制一套查询工具 + +按 D-629 将六个读取能力和对应输入 schema 迁回各自领域:InfoBaseManager 拥有 retrieve/get_entities; +GraphNavigationRetrievalManager 拥有邻域/路径/连通性;Resolver 领域拥有内容方法发现与调用。 +禁止创建 app/agent_tools 或等价中央业务集合。保持 exact IDs 与公开结果语义,schema 去掉 Organization +偶然命名。注册声明与必要边界投影随领域放置,不强迫普通领域调用接受 Agent Tool 消息类型。 +按 D-630 保留 controller/service 分离:注册普通 Tool handler,由其调用领域方法;不对业务类方法 +机械叠加工具装饰器,也不为满足单 input model 注册要求改造 service 签名。 +写入工具继续归原 owner,显式 bootstrap 加载同一 AgentManager registry。 +具体拆分如下;这是职责安排,不要求每行新建文件或增加一层调用。 + +| 领域 | Service 工作 | Tool controller 工作 | +| --- | --- | --- | +| InfoBase | retrieve 组合既有 lexical/semantic;get_entities 复用实体 services | 接受既有工具输入并投影结果,不把 JSON-only 限制放入业务方法 | +| Graph navigation | 复用已有邻域、路径、连通性方法;仅补必要的实体邻域选择 | 选择对应入口、参数接合与结果投影,不复制图遍历 | +| Resolver | Manager 拥有方法发现/分派,Resolver 实例执行内容读取 | describe/invoke 工具合同、动态 schema 与输出表示;不复制方法 registry | +| Agent Query | Sink 拥有回答提交合同及结果收尾 | submit_query_result 接合;不直接写 Job 或 graph | + +控制器及其输入 schema 随领域组织,不从 service 反向导入 controller。组合根显式加载各领域工具, +避免业务包 __init__ 为导出普通类型而隐式加载所有工具/Organization。更新旧调用者与常量导入, +不保留 Organization 作为通用工具发现入口。可共用的纯序列化机制使用现有 Agent 基建,不能借此 +重新建立跨领域工具业务集合。 +清除读取路径对 OrganizationError 等写入合同的偶然依赖,不搬整包形成转发层。静态检查全部引用,再 +用实际 Tool 发现和一次原 Organization 运行证明注册完整。现有 bytes 限制按 D-625 保留。 + +## P2:一个 Sink 实例,一种 Job 合同 + +SinkBase hooks 改为默认 no-op;保留 app 参数和 MCP override。SinkManager 补本地实例访问,并将 +同实例 enable/disable/config update/delete 管理操作串行,修复预演已复现的启停交错;关闭复用内部 +操作,shutdown 不清除持久 enabled intent。不锁运行 Job,不引入分布式锁或 reconcile/snapshot/rollback。 +AgentQuerySink 拥有 config.agent、普通 submit_query_result 和执行结果提取;类型级 JobHandler 在 +bootstrap 注册一次,不随每个实例重复注册。Job parameters 引用 sink 和 query,预算复用 Job 字段。 + +受理路径复用 JobManager.create,claim 前复用本地 Sink 和 AgentManager.can_execute;领取后执行 +AgentManager.run 并 await Turn。按 D-622/D-627 收尾,普通返回、预算、无提交、异常/取消的映射由 +Query owner 处理,不改 Job 通用状态机。输入只在真实边界验证,工具结果不再经过一遍业务校验。 + +同步 runtime type 注册与必要 built-in Job profile,检查 db init/ready 对 catalog 的实际要求。复用 +已有 sinks/jobs/agents 表,不预设 schema migration;若 catalog-only 更新需要维护部署 manifest, +按现行工具生成而非添加新表或绕过 readiness。Sink instance/Agent 不被 seed 自动创建。 + +## P3:实例 endpoint 与独立 CLI + +AgentQuerySink 启动时挂载自己的 POST /sinks/{id}/query,关闭时只移除自己的 routes,并处理 FastAPI +OpenAPI cache。沿现有普通 Core REST 认证依赖,不改成 MCP PAT,也不引入 Peer delegation。 +创建 Job 后返回 202/Job/Location,不 await Agent;generic /jobs 仍独立。 + +cli/ 新增 sink 管理组与 query 命令,复用现有 Click/Pydantic 输入、HTTP、动态 schema、分页与文件输出 +机制。CLI 不 import Core,不复制 prompt 或业务结果校验。核对 Sink 列表与 type/config schema 的真实 +REST 形状,需要的分页按已有 REST 规范接合,不能给未分页接口加无效 CLI 参数。 +动态 query --schema 消费启用实例的运行时路径,不以 checked-in 静态 OpenAPI 代替运行事实。 + +## P4:文档、检查与发行意图 + +文档按 owner 更新:Core 本地 Sink/REST/Agent 工具边界,使用文档内一份推荐 AgentForm,cli/README +提供完整接入步骤并引用模板。删除被迁移的旧 owner 说法,不重复维护工具说明或 prompt。 +落实 [controller/service 文档修正计划](../../documentation-promotion/spoke-unit-tdd.md),在既有架构 +authority 中说明分层,并由 Agent subtree AGENTS 引用;不以新增规范文件代替清理含糊的旧说明。 +同时落实 [D-631 文档 authority 纠正](documentation-authority.md):迁走消费者文档中的依赖规范, +清理旧 Rumination/Agent 合同副本;长期判断准则归 Hub meta,具体实现合同留对应 Spoke owner。 +文档修正按以下依赖顺序推进,可与对应代码同步而非全部推迟到 P4 末尾: + +1. 逐项核实 documentation-authority.md 的 claim/owner,先明确现行合同,再迁移过期或越权声明。 +2. 在 Hub source 的既有 ownership 文档中落长期准则,skill 只引用;Core 架构文档落 controller/service + 合同,各领域文档拥有具体行为,消费者文档保留组合事实和引用。 +3. 核对 Organization/semantic retrieval 的旧副本、相关 Sink/Resolver 文档与导航;已发现的图查询和 + Rumination authority 错置均在本轮纠正,不仅添加新段落而保留旧规范。 +4. 按另行授权先提交/推送 Hub,再单独提交 Core shared-ref bump;Core 本地文档/代码另行提交。 + 本次 packet 提交授权不包含这些后续 Git 操作。 +5. 检查单条合同是否只剩一个定义 owner、摘要是否忠实、引用是否可达,以及实现调用链是否符合分层。 + 不新增 claim registry、文档 gate 或为了准则而建立另一份通用框架。 +检查 docs/openapi.json 的生成边界,静态文档不伪装包含尚未启用的动态实例。 + +按现有 release tooling 为 Core 与 CLI 添加 Towncrier fragments,版本由独立 Release PR 消费, +不在功能 PR 人工改版本或重做发布编排。运行 pdm run check、pdm run -p cli check 和 +pdm build -p cli,并按实际改动运行 release/migration checks。构建产物不进入 Git。 + +## P5:验收与交付 + +先以 wheel 对接 preview 完成 A1–A4 与共享工具回归;语义问题优先检查真实轨迹和 evidence,不凭 +失败就追加 prompt 规则、工具或更大预算。修正与复跑均记录。接着按经确认的交付终点、另行取得的 +Git/发布授权推进。部署成功与 PyPI 可安装都以实际结果核验,不以合并或 CI 绿色替代。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/packet.md b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/packet.md new file mode 100644 index 00000000..c115a097 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/packet.md @@ -0,0 +1,45 @@ +# Agent Query Sink + +- **阶段**:Human review。原实现的 P1–P4、本地门禁、真实模型 A1–A4 与镜像 smoke 已通过; + 本轮注册完整性、schema/route rationale 与历史文档状态修正已完成,冷启动/路由 smoke 与本地门禁通过。 + PR #111 保持 draft 且未合并。 + 证据见 [implementation-evidence](implementation-evidence.md)。 +- **目标与边界**:[D-611](../../decisions/D611-D620.md)。由 AI 组合既有 info-base 原子查询,承担检索决策并交付相关信息;不另建索引或接管下游创作。 +- **位置**:`feat/agent-query-sink`,core-py root worktree;从干净且与 origin/main 一致的 `676886a4d2242be2f14465523c3267a126b60fd3` 切出。决策保留 D-611–D-650。 +- **已确认交付物**:[D-620](../../decisions/D611-D620.md) 修正 D-612:基于 info-base 回答信息需求,实体是可寻址依据而非答案上限;允许跨材料比较、解释与有依据的推论。 +- **已确认调用旅程**:[D-613](../../decisions/D611-D620.md):独立检索调用,以既有 Job 承载。 +- **已确认消费入口**:[D-614](../../decisions/D611-D620.md):普通 Core REST + CLI;不含本轮 MCP/client-web 接入。 +- **已确认配置归属**:[D-615](../../decisions/D611-D620.md):实现 AgentQuerySink,使用 SinkBase 实例与 sink.config;不另建 deployment config。 +- **已确认实例接合**:[D-616](../../decisions/D611-D620.md):config 引用 Agent definition,禁用不隐含取消已开始 Job。 +- **已确认入口与基类修正**:[D-617/D-618](../../decisions/D611-D620.md):hooks 默认 no-op;Agent Query 自有 REST 入口,背后创建 Job,通用 Job API 亦可提交。 +- **已确认 REST 受理回执**:[D-619](../../decisions/D611-D620.md):202、Job 与 Location;不等待检索完成,后续复用 Job 控制。 +- **已确认结果合同**:[D-621](../../decisions/D621-D630.md):answer + references,正文对照实体依据;不再以 matches 列表为主体。 +- **已确认结果提交**:[D-622](../../decisions/D621-D630.md):普通 submit_query_result 工具,Job 收尾尽力保存历史中已成功提交的回答;无实时发布/恢复保证。 +- **已确认 REST 生命周期**:[D-623](../../decisions/D621-D630.md):query endpoint 随 AgentQuerySink 实例挂载/撤下;请求与 CLI 接口保留,通用 Job API 独立。 +- **已确认工具接合**:[D-629](../../decisions/D621-D630.md):六读取能力迁回各自领域,禁止 app/agent_tools;保留 D-624 的单一 registry、exact IDs 与初始 definition。 +- **已确认内容边界**:[D-625](../../decisions/D621-D630.md):使用文本/JSON 与媒体文字子图,不新增原始媒体工具结果通路;保持证据缺口可见。 +- **已确认配置旅程**:[D-626](../../decisions/D621-D630.md):文档模板 + 既有 Agent 管理 + 新增普通 Sink CLI 管理,不要求本地 checkout。 +- **已确认结束语义**:[D-627](../../decisions/D621-D630.md):正常 Turn 有交付则 finished,未交付则 failed;保留真实中断状态。预算只由 runtime 控制,不向 LLM 暴露。 +- **当前复核面**:[Impact Handshake](impact-handshake.md)。[四条验收旅程与交付终点](acceptance.md) 已由 D-628 确认;[实现计划](implementation-plan.md)、[preflight](preflight.md)、[实测记录](preflight-evidence.md) 与 [corpus](corpus.md) 已准备。 +- **事实依据**:[代码与边界调查](research.md)。区分读取工具可发现的方法、能够返回的内容和模型实际能够理解的输入。 +- **下一步**:由 Sir 继续 review PR #111 与 Hub PR #29;本轮修正提交为 `daab9d6`,最新 CI/Preview 结果以 + PR checks 为准。后续合并、版本准备、production 与 PyPI 复验需 + 单独授权后才能关闭 unit。 +- **文档修正**:D-631 已落实到领域 owner 文档;长期准则见 Hub draft PR #29,具体运行合同仍留 Core。 + +## 工作方式 + +遵循 parent 的 [discussion loop](../../collaboration/index.md) 和 [design taste](../../design-taste.md)。 +新方案由 Agent 调查、推导并推荐,一次呈现一个重要复核面;低风险推论不逐项提问。讨论即时写回, +确认的决定进入唯一 decision register。未稳定的文档压力只记录于本 packet,不边讨论边改 durable docs。 + +完整流程为:产品设计 → 技术设计 ↔ 验收与实现计划探查 → 预演 → 冻结 baseline / Impact Handshake → +明确实施授权 → 实现与端到端验收 → 按约定交付并关闭。Parent task 不因本 unit 关闭而自动结束。 + +## 潜在影响与文档 owner + +涉及 Core 的查询执行、Agent 工具适配与普通 Job API,以及 CLI 消费;MCP/client-web 的专门接入已排除。 +不预设新的 Sink framework、独立 Agent runtime 或数据库表。已选择复用 Job,具体 handler 合同与横切改动由调用旅程传导。 + +后续稳定的 Sink/primitive query 关系归 Hub 产品/跨 unit 合同;运行、工具归属及具体接口归相应 Spoke。 +当前只记录 promotion pressure,不编辑 `docs/_shared`,也不将旧 Organization 待提升项变成本 unit 的前置。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/preflight-evidence.md b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/preflight-evidence.md new file mode 100644 index 00000000..271597e8 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/preflight-evidence.md @@ -0,0 +1,104 @@ +# Preflight 实测记录 + +本文保留实施前实验的历史证据,不代表当前工作树或交付状态。后续实现与 Preview 验收见 +[implementation-evidence](implementation-evidence.md),当前阶段见 [packet](packet.md)。 + +2026-09-20,基线 676886a4d2242be2f14465523c3267a126b60fd3;git ls-remote 确认 origin/main 仍为同一 +SHA。当前工作树只有本 task packet 改动。隔离脚本在忽略目录 `.runtime/agent-query-preflight.kj5RQX/`, +不作为新增自动化测试或发布源码。PDM 2.28.0;本机 FastAPI 0.139.2、Pydantic 2.13.4、HTTPX 0.28.1。 + +## 动态路由与 schema + +执行 `PYTHONPATH=. pdm run python .runtime/agent-query-preflight.kj5RQX/spike.py`,使用真实 FastAPI +TestClient,但没有数据库、模型或生产服务。先请求 OpenAPI,再添加两个实例 router,分别调用、撤下 +其中一个、重新加入并再次调用。两实例独立、schema 刷新、被移除路径 404、另一实例正常、重启无重复 +均通过。普通 dependency 在实际请求中执行。 + +当前 FastAPI include_router 产生 `_IncludedRouter` 对象,而不是旧版假设的扁平 APIRoute 列表。 +第一次脚本使用旧结构计数断言失败;改为核对实际注册对象、HTTP 行为和公开 schema 后通过。实施时 +保存本实例实际新增的 route 对象,关闭时按 identity 移除;不通过全局 path 猜 owner,也不依赖私有类名。 +挂载/撤下后清空 app.openapi_schema 即可重新发现。CLI 用具体 `/sinks/7/query`,不能查不存在的 +模板键 `/sinks/{sink_id}/query`。静态 OpenAPI 生成脚本不启动 Sink,此事实无需改变。 + +## Thread 的六条隔离路径 + +同一 spike 复用真实 Thread、InMemoryThreadPersistenceBackend 和 Pydantic 工具输入,仅替换模型 +响应;不连接数据库或构造产品测试模式。结果如下: + +| 路径 | runtime 结果 | 闭合历史中的成功结果数 | +| --- | --- | --- | +| 最后一次模型调用提交 | max_model_calls | 1 | +| 正常文字结束、未提交 | completed | 0 | +| 预算结束、未成功提交 | max_model_calls | 0 | +| 提交后下一次模型调用期间取消 | cancelled | 1 | +| 提交工具结束、同批另一个工具未结束时取消 | cancelled | 0 | +| 提交后 wall-clock timeout | timeout | 1 | + +取消/超时均结束实际 Turn Task 与等待中的子任务。此证据证明 asyncio 传播和内存历史边界,不声称 +新 Query JobHandler 已实现或数据库收尾已验收。AgentManager.run 在 start_turn 后没有 await,正常 +返回 Thread 后直接 await current_turn 即可传播取消,不增加 detached runtime。 + +## Sink 启停交错 + +执行 `lifecycle.py`,保留真实 SinkManager.enable/disable,用局部替身替换持久化与 on_start 中的 +外部等待。复现 enable 挂起 → disable 完成 → enable 恢复:enabled=false,running=true,on_close +未调用。用同一实例的 asyncio.Lock 包住完整两次管理操作,得到 enabled=false、running=false,且 +on_close 已调用。 + +实施采用 Manager 内同实例生命周期串行化,不引入分布式锁、generation、回滚或 reconcile。运行 Job +不持此锁,disable 不等待 Job;无关 Sink 互不串行。配置更新/删除同实例也应使用同一管理边界,避免 +启动中的旧 model 覆盖已保存配置或在删除后挂入;shutdown 沿同一关闭内部操作,不能把 enabled intent +清掉。失败仍保留可观察的 intent,实验不支持增加自动恢复承诺。 + +## 真实模型与内容体量 + +`handshake.py` 通过既有 AlibabaModelStudioDialect 调用 qwen3.5-omni-flash,两次真实请求完成 +tool call → JSON ToolResult → 最终文字闭合,1.29 秒。只使用现有本地 dotenv 内 Provider 凭据,未 +打印值、未写入任何 deployment 或改动 Agent/AI 源码。 + +`content_and_schema.py` 加载六个现有读取工具及 core Resolvers,将完整 schemas 交给同一真实模型; +服务端接受,模型选择 get_entities,实际 arguments 通过该工具现有输入模型。schema JSON 共 +13,676 bytes;SHA-256 为 ba490bf85e162d0585d53005ca55055bac1e0dfc88f24dbcd68ab37afd292102。 +该实验仅证明 schema/协议相容,不证明模型已经成功检索新 Query 的 corpus。 + +真实 HTMLResolver 对 SQLite 快照得到 12,002 字符、JSON 编码 12,262 bytes 的文本。拟用 corpus +不要求引入新文本分片/缓存工具。保持现有完整返回,不静默截断;任意超大材料的上下文管理仍是已知 +上限,不能从该测量推导任意规模都可用。实际回答质量与长历史成本在 preview 记录。 + +## 工具移动、catalog 与运行环境 + +六 read handlers、_resolver_input_model、_project_json/_contains_bytes 迁出 organization/tools.py; +organization_behavior.py 的对应 read inputs、union 分支、EntityReference 同归共享适配合同。 +不要与 MCP/REST 的同名参数类合并,它们有各自 wire 形状。get_connected_components 使用了写入 +_exact_result helper,迁移时保留普通 ValueError 投影而去掉 OrganizationError 依赖;_exact_result +继续留给 Organization 写入。调用点由 rg 检查:Organization __init__ 导出及既有黑盒验收 imports +需更新,不能只让新 Sink 导入时才注册。持久 Agent definitions 的 exact Tool IDs 不变。 + +catalog.py::reconcile_builtins 已 upsert BUILTIN_JOB_TYPES;readiness.py 按相同 profile 检查字段。 +增加 Query Job 属于已有 catalog 数据变化,db init 会收敛;无需新表、列或 schema migration。 +Sink types 仍由 SinkManager.startup/sync_sink_types 发布,不能把 Sink instances 或 Agent seed 化。 + +SVC 最初检测到旧 dev image b3ccb00/source_matches=false。执行 `svc dev ensure database --repo .` +后已在同一声明的远程 Docker/volume 上更新为 676886a,migration head d41cc84db0c5,readyz=200, +未 reset/stop 数据库。只读盘点:13 Blocks、1 个 text-embedding-v4 模型、0 个 Agents。 +production 的受保护 ai models 读取成功,当前为空;正式验收要先配置专用 Provider/模型及基础检索, +不能假定旧验收配置仍在。此准备在实施后的相应验收环境完成,不是本次对 production 的写操作。 + +基线 pdm run lint、pdm run typecheck、pdm run -p cli check、check_lock.py 与 +check_migration_history.py 均通过。release.py projects 包含 core 与 cli;发行意图分别落根 .changes/ +与 cli/.changes/,独立 Release PR 消费,CLI main workflow 用 Trusted Publishing 发布。 +没有为本 unit 建 PR,故新功能的 preview 与 production 验收均尚未执行。 + +## D-629–D-631 后的补充核对 + +现有 AgentManager.tool 在注册时读取普通函数的唯一 Pydantic input 参数并保留 handler;input_model_factory +在绑定 Thread 时执行。controller/service 分离后仍是这一路径,不要求新增 classmethod 注册支持。 +GraphNavigationRetrievalManager 已有三个图查询的业务方法,ResolverManager 已有方法合同与 invoke_method; +迁移只应补真实缺少的领域组合、移动 controller/schema,不复制查询/反射实现。 + +当前 Organization __init__ 导出六个读取工具常量,run.py 又经 Organization 路由/Jobs 导入触发注册; +所以 P1 必须改组合根和消费方 imports,不能只移动函数。普通业务模块不得反向导入 controller, +冷启动的实际无循环导入与完整工具发现是在改动后验证的事项,不声称本次只读核对已经证明。 + +文档核对发现 controller/service 的已有规则未被正确应用,且消费者章节在重复定义依赖合同。 +这不能靠迁移源码自动修复;D-631 的逐项 owner、旧位置处理及 Hub/Core 分工已纳入实现计划。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/preflight.md b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/preflight.md new file mode 100644 index 00000000..03e76fc7 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/preflight.md @@ -0,0 +1,32 @@ +# Preflight:实施前结论 + +本文为实施前的历史快照;以下状态截至当时基线。后续实现、Preview 验收和 review 修正见 +[implementation-evidence](implementation-evidence.md),当前阶段见 [packet](packet.md)。 + +状态:2026-09-20,关键源码调查与隔离实验已完成,可以进入 Impact Handshake;新功能尚未实现或验收。 +D-629–D-631 的修正已补充源码与文档核对:保留普通 Tool controller,注册机制无需扩展;移动后的 +冷启动验证和文档 authority 纠正已纳入 P1/P4。其它运行证据保持有效。 +执行次序来自 [实现计划](implementation-plan.md),验收问题来自 [acceptance](acceptance.md)。 +具体命令、结果与证据局限见 [实测记录](preflight-evidence.md),真实材料与问题依据见 [corpus](corpus.md)。 + +| 检查面 | Preflight 结论 | 实施与验收需保持的边界 | +| --- | --- | --- | +| 动态 REST/schema | 两实例挂载、独立撤下、重启、schema 刷新实验通过;当前 FastAPI 保存 router 包装对象 | 保存实际注册对象并按 identity 移除;CLI 查具体实例路径;静态 OpenAPI 不伪造实例 | +| 读取工具 owner | 六工具的输入、helper、导入点与 OrganizationError 偶然依赖已枚举 | exact IDs 不变,显式 bootstrap;旧 Organization 运行需要回归 | +| Job 结果闭合 | 真实 Thread 的六条取消、超时、最后轮次与无提交路径通过 | 未闭合 batch 可丢失结果;新 handler 的数据库收尾仍须端到端核验 | +| 启停与 claim | 已复现启动未完成时禁用却留下运行实例;同实例管理操作串行可消除该交错 | Manager 内局部修正,不锁运行 Job,不增加分布式协调或恢复框架 | +| catalogs/readiness | 新 Job 可由现有 builtin profile + db init 收敛;Sink type 走既有启动同步 | 不增加表、列或 schema migration,不自动创建实例/Agent | +| 内容体量 | SQLite 正文投影 12,002 字符;真实模型接受全部六个读取工具 schema | 不静默截断,不承诺任意长材料均可容纳;preview 核验真实轨迹 | +| 环境与模型 | dev 已收敛到当前基线且 ready;真实 Alibaba dialect 工具调用闭合通过 | production 目前无 AIModel,验收需配置;未对 production 写入 | +| 语义 corpus | SQLite、InKCre 检索文档与 NASA 真实媒体已固定材料/摘要和问题依据 | 不用固定产品 ID 或测试专用行为;实际建图与索引留到验收环境 | +| 发布 | main 基线、Core/CLI release projects 与 fragment roots 已核对,静态基线检查通过 | 新功能尚无 PR/preview;按 D-628 验证实际发布,不借用旧成功记录 | + +源码调查还表明 Agent/AI can_execute 是静态本地判断,不探测远端 Provider;不把远端在线作为 claim +前提。缺少单个查询路径不能升级为整个 Job 不可领取。确有无法执行的声明能力时沿已有资格判断处理。 + +run.py 的 require_peer_jwt 在 core_router dependencies 上,不是全局 middleware。动态 route 需显式 +复用同一依赖,不能假定挂入 api_app 后自动继承;此为正常 REST 接合检查,不新建认证方案或安全审计。 +独立只读复核特别指出 CLI path key 与 Organization 冷启动注册是两个实际回归点,已纳入预演/验收。 + +预演不能以新增永久测试接口或产品分支换取方便。临时实验与结果放在 unit evidence 或忽略的工作目录, +保留命令与结论;新自动化测试需另有准入理由,不由这个列表自动产生。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/product-design.md b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/product-design.md new file mode 100644 index 00000000..38904e61 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/product-design.md @@ -0,0 +1,92 @@ +# 产品方案:把检索判断交给 Agent + +已确认的定位、交付原则、独立 Job 调用与消费入口见 [D-611–D-620](../../decisions/D611-D620.md)。具体执行体验仍待设计, +不能将交付原则获批等同于整份产品合同已冻结。 + +## Caller 交付目标,Agent 承担检索决策 + +Caller 描述需要寻找的信息,并可提供已有线索或工作上下文;无需先知道查询词、检索模式或 graph 路径。 +Agent 据已取得的信息选择查询、改写线索、读取候选内容、沿关系探索,再筛选交付结果。无需每次调用全部 +检索能力,也不预设 lexical → semantic → graph 的固定步骤。 + +这里的增量不是“将多个结果列表合并”,而是根据中途得到的信息改变下一步查询,并判断哪些信息真正符合 +需求。例如找到一篇提及某个方案的文章后,可沿引用关系寻找原始来源,而不是把提及本身当成答案。 + +## 已确认:从匹配信息到回应信息需求 + +D-620 修正 D-612 的交付上限;原 matches + reason + optional summary 草案不再作为结果基线。 +让 caller 提出信息需求,Agent +不只定位材料,还可根据材料完成理解、比较、关联与有依据的推论;有用的答案未必预先存在于某个 Block +中。不能用“只能交付已存在的实体”作为能力上限,也不能把承担回答所需推理的文本一律降为辅助 summary。 + +例如,“过去为什么放弃某个设计”需要从提案、反驳和决定中重建理由;“两个方案是否真的矛盾”需要检查 +各自前提与适用条件;“这些资料对当前问题意味着什么”需要结合 caller 提供的约束得出有依据的解释。 +这些是待复核的能力场景,不代表已能正确识别全部历史或穷尽证据。 + +边界以行为与 authority 区分,而不是以“是否生成新文本”区分:Agent Query 为本次问题形成回答并 +保留支撑判断的实体引用;Organization 将可复用的组织成果写入 info-base;下游工作仍由 caller 拥有。 +本轮不因此增加 organization 写入、外部研究、通用执行工具或持续聊天。推论与原文事实应在表达中分清, +不能将临时发现的联系描述为图中已经持久化的 Relation,也不能把有来源等同于推论必然正确。 + +仍应按请求交付:找某条原文时,直接返回材料足够;提出问题时,回答可以成为主体。不要要求每次生成 +长文,也不要通过额外的分类字段、模式选择或预制报告框架让 caller 先替系统决定如何理解问题。 +产品提升已确认,具体结果 schema 与语义验收需据此设计,不只是将 summary 换名为 answer。 + +## 三类压力场景 + +| 检索目标示例 | Agent 应承担的判断 | 有用的交付 | +| --- | --- | --- | +| “找我收集过的关于取消异步任务后清理资源的资料,记得提过 shield” | 用具体词找到线索,读内容判断是否满足条件,必要时换语义查询或追溯相关来源 | 相关原文与命中依据,而非 Python 教程 | +| “哪些材料反对把所有能力机械展开成工具?它们的理由有什么不同?” | 找到讨论、检查实际论点与出处,避免只凭主题相似下结论 | 支撑不同理由的材料及简短对照,而非脱离来源的最佳实践 | +| “从这条笔记提到的方案,找出它引用的原始依据” | 从明确实体出发读取并沿相关关系查找,路径不存在时区分尚未找到与不存在 | 可打开的依据及相关连接;关系尚未建模时不编造路径 | + +这些是产品推演案例,不是已验收的数据集,不要求产品实现识别特定词或走固定调用序列。 + +## 完成语义的候选边界 + +检索可以返回少量或零条结果;本次未找到不是证明 info-base 中绝对不存在。某一路径不可用或预算耗尽, +不应伪装成完整的负结论,也不必抹掉已取得的可用信息。具体如何交付部分成果、如何等待/停止,留待执行体验复核。 + +产品意义上只发起读取与检索,不主动收集外部新资料、不发起 organization 写入或索引维护。Resolver 的普通读取 +仍可能按已有合同 materialize;不能因此把本功能描述为数据库绝对无写入。是否开放相关读取选项由后续方案明确。 + +## 后续讨论顺序 + +1. 按 D-620 的回答信息需求方向设计结果与语义验收,不继续沿用旧的实体匹配列表作为唯一交付主体。 +2. 再确定 MVP 如何发起、接收结果和控制等待,以及最小的实际消费者;不从现有 Thread API 直接推出 Chat UI。 +3. 据此设计 Agent definition、工具复用与 ownership、Sink realization、协议及预算/失败语义。 +4. 用真实 corpus 和模型轨迹拟定验收,区分实现跑通与检索是否有用;不要求固定搜索次序、固定措辞或相同最短路径。 + +## 独立检索调用 + +D-613 确认 MVP 以一次明确检索需求为单位,而非必须建立持续对话:caller 提交目标,可附上下文和已知实体线索; +内部 Agent 自行多步检索;交付符合 D-620 的结果后结束本次工作。输入尚未决定字段,不能把“目标、上下文、 +线索”机械做成三个必填参数。 + +例:caller 要求寻找工具粒度设计资料,并说明自己正在设计 MCP 接口。Agent 可以据此排除虽主题相似但不 +回应工具粒度的材料。caller 随后要求“只保留讨论动态 Extension 能力的材料”,可作为带着前次结果线索的 +新查询;这不承诺服务器记住上一轮未交付的全部探索信息。 + +该取舍优先服务独立调用与组合,暂不提供内部 Agent 主动追问/暂停等候用户、持久聊天历史或跨重启续查。 +代价是后续查询可能重读内容、重走部分路径;若真实连续探索出现明显损失,再评估 thread 续用,而不是 +在缺乏消费场景时先建设会话管理。现有 Thread 能继续 start_turn 是实现事实,不自动决定本产品需要暴露会话。 +若发现同名项目等必须由 caller 补充的条件,结果说明歧义并保留已取得的信息,caller 补充后发起新查询; +不猜测该条件,也不让内部执行一直等待用户答复。 + +一次检索不等于一次模型调用,也不等于保持 HTTP 连接等待完整执行。D-613/D-618 采用既有 Job:caller +通过 Agent Query REST 提交检索目标,服务端创建 Job;也允许直接用通用 Job API 创建同类型任务。 +之后通过同一 Job ID 查询与等待结果,需要时显式请求停止。等待、预算与停止复用既有 Job 合同。 +进行中结果的可见性还未承诺;不能把已有 Job 状态查询误称为实时搜索进展。 + +现有 CLI/REST 和 MCP 均可成为面向外部 Agent 的入口,但只有前者纳入本轮。client-web 有应用级 RecallSearch, +其当前 query 路由会重新执行 lexical 检索,不能假定新增按钮就能展示 Agent Query 的结果。 + +## MVP 消费入口 + +D-614/D-618 确认以 Agent Query 自己的 Core REST + CLI 形成完整消费旅程。其入口表达检索而不是要求 caller +先组装通用 Job envelope;Job 是其后台执行机制,也是可直接使用的组合入口。CLI 已有 Job schema 发现、 +读取、有界等待和 abort,复用这些行为,不运行本机检索 Agent。 + +暂不纳入 MCP 和 client-web 的专门接入。MCP 当前七项读取工具没有 Job admission/控制接口;接入需要明确 +新增工具与原子检索工具的关系。client-web 则需要接纳异步结果和检索说明,而不只是复用 lexical 的 q 路由。 +两者有产品价值,但都是额外的消费体验范围,不能把它们当作免费的 adapter。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/research.md b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/research.md new file mode 100644 index 00000000..2cf333a0 --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/research.md @@ -0,0 +1,212 @@ +# 已核验的复用基础 + +2026-09-20,基线 `676886a4d2242be2f14465523c3267a126b60fd3`。仅静态代码/合同调查,未执行模型或生产数据请求。 + +## Agent 执行不需要重新发明 + +`app/business/agent/main.py::AgentManager.run` 读取持久 Agent definition,绑定工具后创建 Thread 并启动首个 Turn。 +`thread.py` 已有并发 Tool batch、每 Turn 模型调用预算、取消和 messages。没有工具调用的 Assistant message +结束当前 Turn;达到预算也会结束,但不保证此时已有最终文字结果。这是结果交付设计需要处理的真实压力。 +当前只实现 in-memory thread persistence;不能据此承诺跨重启会话恢复。 + +Agent/AI 不理解 info-base graph;查询策略、工具选择和结果含义属于其 caller。`AgentForm` 已包含 model、 +system_prompt、tools、tool_choice 与 max_model_calls_per_turn,不应为本 unit 另造一份同义 definition。 + +## 读取工具已经存在,但物理归属还不能直接照搬 + +`app/business/organization/tools.py` 已注册 `retrieve`、`get_entities`、`resolver`、 +`get_entity_neighborhood`、`find_path`、`get_connected_components`。 +`retrieve` 可独立执行 lexical/semantic 或并发两路,保留各路结果/错误,不强行合成分数。 +图工具交给 GraphNavigationRetrievalManager;Resolver 元工具发现/调用当前 exact Resolver 的 typed read method。 + +这些读取 tools 与 Organization mutation tools 同文件,输入模型主要位于 `schemas/organization_behavior.py`, +模块还导入具体 Organization behaviors。因此“handler 已有”不等于“新 sink 应依赖整个 organization 模块”。 +后续应核对最小的正确复用位置与注册入口,保持现有 exact Tool IDs;本轮尚未决定代码搬迁方案。 + +MCP 的七个工具是另一组协议投影,`app/business/sink/mcp.py` 直接调用各 owner,并拥有 MCP Resource 交付。 +它们不是 AgentManager 的 native Tool registry。不能为了复用能力而让本机 Agent 必须通过 MCP 调用自己, +也不能把 MCP wire schema 当作新 sink 的领域合同。 + +## 原子查询与 Sink 保持边界 + +- Lexical/semantic managers 返回既有实体及命中/排序信息;maintenance 是独立行为。 +- GraphNavigationRetrievalManager 只查询既有图,返回邻域、路径、连通结构;不执行 Resolver。 +- Resolver 解释内容;storage pointer 不能被检索 Agent 当成实际内容。 +- SinkManager/Base 已有注册、持久实例、Peer enable intent 与 start/close,但 CLI 产品上也是 sink, + 并不是一个 SinkBase runtime instance。故“称为 sink”不自动决定必须新增实例行、endpoint mount 或 lifecycle。 + +依据:`docs/30-unit-tdd/{business-pipeline-and-authority,mcp-sink,lexical-retrieval,graph-navigation-retrieval}.md`, +`app/business/{agent,sink}/AGENTS.md`,上述当前源码。技术阶段再逐项追调用方,不以这些证据冒充完整 preflight。 + +## 已恢复的讨论依据 + +任务的 design-taste、collaboration protocol、Agent Tool common patterns 和 validation-boundaries 已重读。 +关键约束是工具可组合、结果保留可寻址身份、定义与 SOP 分离、按真实轨迹诊断、不把部分成果丢弃, +以及不因重复分层增加校验边界。暂不新增同义的 task-wide guideline。 + +已按当前 AGENTS 的 advisor 指南取得一次独立、只读的产品判断:可寻址结果与简短文字结论不是二选一, +模型 Turn 正常结束也不证明检索目标达成。主代理据现有 Thread 实现复核,保留为方案/验收压力, +不把 advisor 建议记为 Sir 已批准的产品合同。 + +## 消费入口补查 + +`app/routes/retrieval.py` 提供普通 lexical/semantic REST,`app/routes/agent.py` 目前仅管理 definitions 和发现 +Tools,没有公开的 Agent Thread execution API。`cli/src/inkcre_cli/commands/info.py::recall` 显式选择 +lexical/semantic,独立返回各路结果。`MCPSink._register_tools` 的 recall 同样是调用原子查询,不是内部 Agent。 + +`client-web/apps/client-web/src/components/recall/RecallSearch.vue` 是应用级入口。当前 Search 调用 lexical, +再将 q 传给 List/Graph overview;这不是一个现成的 Agent Query 结果容器。是否扩展其体验是产品范围决策, +不能由目录位置或已有搜索输入框直接推导必须本轮改造 UI。上述读取没有修改 client-web。 + +## Job 复用调查 + +`JobModel` 已有 parameters、state JSONB,pending/running/terminal 状态和 timeout_seconds; +`JobManager.run` 在 can_handle 后原子领取,await handler,并统一处理取消、超时和异常。 +`LexicalMaintainJobHandler` 已将报告写入 job.state;`JobRepository.close` 持久化 state 与终态。 +普通 REST 和 CLI 已有创建/get/abort、有界 wait;不需要另建通用 Task API。 + +当前 handler 修改 state 仅影响内存模型,close 才写数据库;没有现成的运行中进展保存接口。 +Agent Thread 的 messages 也仅在内存。结果可优先放在 Agent Query 自己定义的 job.state payload 中, +但不能声称现在已经支持边执行边返回候选、崩溃后保留未落库成果或恢复探索。 +技术设计须明确“检索提交的结果”和“工具访问过的候选”有何区别,避免将所有原始命中冒充筛选结果。 + +另一项需预演的真实边界:`JobRepository.close` 只更新仍 running 的记录;超时清扫也会关闭记录。 +结果发布不能假定 handler 的 finally 总能将成果写回。先确定需要保留的结果单位和时点,再选择最小保存路径; +当前不据此引入 checkpoint、重试或持久 Thread。 + +## 配置与 Sink 实例的区分 + +`SinkBase` / `SinkManager` 提供具名持久实例、config、Peer enable intent 与 on_start/on_close。 +MCPSink 使用该生命周期持有 SDK session manager 和 exact endpoint mount;这是当前实现的资源需要。 +`docs/30-unit-tdd/business-pipeline-and-authority.md` 已明确 CLI 是产品 sink,但不是 SinkBase instance。 + +`organization/rumination.py` 展示 deployment config 选择既有 Agent 的模式;实际 model/prompt/tools/预算 +仍由 `agents` 持有。此前主代理/advisor 据无常驻 endpoint 建议省去实例,经 D-615 修正:Sir 要求 Core 内 +该 sink 使用既有 Sink 实例组织配置与运行。保留 Agent 引用模式,选择关系的 authority 改为 sink.config。 +不能将“无需常驻 endpoint”推导为“实例配置/生命周期没有价值”。 + +进一步核对:当前 SinkManager 有本地 `_running` map,没有公开的 running-instance 获取方法;JobHandler +是类型级 registry,不能按 Sink 实例重复注册。同一 Job type 可以按 parameters 中的 Sink 选择实例, +无需新增通用 Sink 执行或派发接口。disable 与执行的产品边界已由 D-616 确认,具体实现仍待预演。 + +## Sink 生命周期必要性复核 + +Sir 追问此前“没有独立 endpoint 启停”的含义,并要求检查 SinkBase 是否过度设计。这里必须区分三件事: +Sink 实例启用/禁用、具体实现拥有的资源启停,以及 HTTP route 挂载/移除。三者不是同义词。 +此前由“没有 endpoint”推导“无需 Sink 实例”的依据过窄,D-615 已撤回该结论。随后 D-618 进一步纠正 +入口假设:Agent Query 本身仍提供 REST,背后创建 Job;不是只有普通 Job REST。D-623 已确认随实例挂载。 + +当前 `SinkBase` 只有注册、配置类型恢复/更新与两个 abstract lifecycle hooks;没有通用 deliver、后台循环、 +endpoint registry、snapshot/restore 或 reconcile。`SinkManager` 管 persisted intent 和本地实例, +`MCPSink.on_start/on_close` 自己管理 MCP SDK session manager 与 `/sinks/{id}/mcp` 挂载。 +Manager 不直接管理 MCP route,因此不能把整个 Sink 模块称为 HTTP endpoint framework。 + +真实的专用倾向在于 `SinkBase.on_start(app: FastAPI)`:两个 hooks 被要求每个子类实现,Manager 也以 +app 已提供作为 runtime 已启动的前提。Agent Query 无需常驻资源,照搬会产生两个空 override;但本机 +Core 当前确实由 FastAPI bootstrap 提供 app,尚无证据要求这轮为无 HTTP host 再造 provider/context 层。 +D-617 随后确认 hooks 默认 no-op、暂留 app 参数的局部收敛;不是重做 Sink framework 的授权。 + +REST 复核证据:`app/routes/job.py::create_job` 已经通过 `JobManager.create` 持久受理、返回 Location 并通知 +worker,HTTP 不等待实际 Job 完成。Agent Query REST 可复用该业务入口,不需要内部发 HTTP 给 /jobs。 +D-619 已确认业务受理返回 202,D-623 确认实例级 route;原 `/jobs` 创建资源返回 201 不因此需要更改。 + +独立只读复核也未发现必须重做基类的理由,并指出 Manager 有一条需纳入预演的交错:enable 等待 +on_start 时尚未写 `_running`,并发 disable 可先更新 intent、发现无实例而返回,随后 enable 挂入实例。 +这是源码推导而非已复现实验,属于 Manager 状态转换问题,不是删除 lifecycle hooks 就能解决的。 +接合 Job eligibility 时不能忽略此交错,也不在本次基类讨论中擅自承诺锁、重试或全局 reconcile。 + +## 结构化结果交付的复用路径 + +D-621 确认后重新核对 `agent/main.py`、`agent/thread.py`、`agent/contracts.py`、`agent/persistence.py` +和 `job.py`/`persistence/job/repository.py`。现有工具边界是单个 Pydantic input → JSON return;成功值 +进入 ToolResult,整个 batch 完成后才连同 Assistant message 一起写入内存 Thread。Sink 可以读取该历史, +无需新增 Agent runtime 回调或模型最终 JSON 解析协议。 + +JobHandler 传入的 job 是执行局部对象,JobManager.run 各终止分支调用 close 保存其 state;失败分支 +合并 error 而非清空 state。独立 Tool 按 job_id 写数据库会增加第二个写入者,又可能被 close 的旧快照 +整体覆盖,因此不推荐此路径。仅让普通工具返回结果、handler 收尾提取,能保持一个 Job state 写入路径。 + +独立 advisor 与主代理源码推演一致:该路径可在取消/异常时尽力保存已闭合消息中的回答,但不提供即时 +持久化。某结果工具已完成而同批其它工具尚未完成时取消,结果尚未进入历史;数据库超时清扫先关闭记录 +时 close 也不会再写 state。这些是具体残余,不以已有结构化工具支持冒充成果必达保证。 + +## REST route 与 CLI 消费入口复核 + +`run.py` 在 bootstrap 前将普通 Core routers 统一挂载,现有 Sink 管理、Job 管理 route 不随实例启停。 +MCPSink exact mount 服务其实际协议 session,Agent Query 则受理 Job,不需要在接入 Peer 执行工作。 +主代理与独立 advisor 曾据此推荐固定的 Agent Query-owned route,被 Sir 于 D-623 否决。错误在于 +将共享 Job admission 合同推成两种产品入口必须具有相同可用性,又让执行基础设施决定 Sink 暴露服务的 +生命周期。当前结论是 AgentQuerySink 自己挂载/撤下 endpoint;/jobs 继续独立,两者没有矛盾。 + +CLI 实际 console script 是 `inkcre-cli`,而非 `inkcre`。`commands/info.py::recall` 当前同步执行 lexical/ +semantic 并独立返回;`commands/jobs.py` 已有 create/get/abort 与 observe 有界轮询。建议独立 query 命令 +投影业务 REST,之后使用已有 Job 控制;不改已有 recall 输出使其有时返回命中、有时返回 Job。 +`command.py` 已有 --input/--input-json/--schema,交付实现应复用,不另建内容输入格式。 + +## 六个读取工具的实际依赖 + +逐一读取 organization/tools.py 的 retrieve、resolver、get_entities、get_entity_neighborhood、find_path、 +get_connected_components,及 schemas/organization_behavior.py 的对应输入模型。读取本体依赖各查询 +manager、BlockService/InfoBaseManager/ResolverManager,不需要具体 Organization behavior;但所在模块 +顶层导入了全部写入行为,organization/__init__.py 又导出工具,故新 consumer 不能只从该包 import 即算解耦。 + +retrieve 同时组合 lexical 与 semantic;resolver 的输入 schema 来自 ResolverManager 的公共方法反射, +无需修改 Resolver 或 Extension 增加 Agent metadata。现有 _exact_result 捕获 OrganizationError,不能 +将这个 helper 连同读取函数机械迁移;共享适配层不应继续依赖 Organization 错误合同。 + +_project_json 会递归拒绝 bytes,然后转换模型/dataclass/普通JSON值;因此 get_solved_content 等方法 +可被发现但其特定返回值未必能交付给内部 Agent。这个限制与 method discovery、实际方法支持情况不同。 +六工具复用并不完成多模态内容支持。初始 Agent 的真实内容能力必须另行明确并纳入验收证据。 + +以下为已被 D-629 否决的历史提案,不是实施依据。主代理与独立 advisor 曾推荐普通 app/agent_tools +适配模块,集中工具专用投影/输入包装,保留单一的 +AgentManager registry。tests/organization/acceptance/agent_definitions.json 是既有验收配置,而非 +可直接作为新功能默认配置分发的权威;新模板应由 Agent Query owner 提供,不让验收数据塑造产品。 + +## 工具结果与多模态输入不是同一条通路 + +2026-09-20 核对 schemas/ai/chat.py:ToolResult.content 是 JSONValue;image/audio/video content parts +用于 UserMessage,而非工具结果。openai_compatible.py::_message_params 把每项 ToolResult 编码为 +role=tool 的 JSON 字符串;AlibabaModelStudioDialect 复用此实现,仅覆盖 UserMessage 的多模态投影。 +因此去掉 _project_json 的 bytes 拒绝或将 bytes base64 化,都不等于模型能看到图片、听到音频。 + +[Alibaba Function Calling 官方示例](https://www.alibabacloud.com/help/en/model-studio/qwen-function-calling) +以 tool_call_id 关联 tool 文本结果。该示例支持当前实现的依据,但不足以断言全部模型/协议都禁止多模态 +工具结果。若本轮需要直接原始多模态理解,应进一步核验目标 dialect 的合法投影并做真实模型实验,不能 +让 Resolver 增加 Agent 专用合同,也不能把 MCP Resource 或 CLI multipart 当作内部 Agent 已有能力。 + +ImageResolver/AudioResolver 的 get_text(default) 不支持;lexical context 返回元数据,可选 materialize +文本 child,但不直接返回 child 的正文。Agent 需沿图读取 OCR/转录及已有解释。PDFResolver 的默认 +get_text 能读取文本层,不代表纯扫描 PDF 已被支持。文本与结构化证据可覆盖多种来源,但不是无损的 +多模态内容替代;仅存在于图像空间关系、音色或视频时序中的证据可能缺失。 + +## 已安装 CLI 的实际配置入口 + +2026-09-20 检查 cli/src/inkcre_cli/main.py、commands/agent.py、source.py、extension.py 和 app/routes/sink.py。 +CLI 已支持 ai models、agent tools/create/update 和普通 JSON 输入/schema 发现,但未注册 sink 命令组。 +Core 已有 Sink types/list/get/create、config PUT、enable/disable/delete;enable/disable 取接入 Peer, +并非 Extension route_to_peer 形式。sink-types 包含 config_schema,因此新增 CLI 投影无需在 CLI 复制 +AgentQuerySink 配置模型。当前没有单个 sink-type GET 或分页,不可机械照抄 Source CLI 路径。 + +推荐通过使用文档交付可编辑 AgentForm JSON,并复用已安装 CLI 的 agent create;这避免将 prompt 放入 +CLI 或增加模板服务。该路径不依赖 checkout,但明确要求 deployment 已配置 AIModel 和基础检索。 +Sink create 不启用实例,需显式 enable;查询 endpoint 因而能通过这一实际配置路径挂载。 + +## Turn 预算边界与 Job 收尾的实际次序 + +2026-09-20 复核 agent/thread.py::_execute_turn:没有 ToolCalls 的 Assistant 写入后返回 completed; +有 ToolCalls 时,先执行全部工具并写入闭合消息,再检查调用次数预算,达到上限返回 max_model_calls。 +因此“最后一次调用成功提交回答”仍会触发 max_model_calls,不能仅凭该值把交付判为失败。 + +job.py::run 在 handler 正常返回时关闭为 finished;异常时记录 state.error 并 failed;wall-clock +TimeoutError 与 asyncio.CancelledError 分别关闭为 timed_out/aborted。Query owner 可以在正常 Turn +返回后判断是否有已提交结果,无需改这些通用分支。独立只读 advisor 与主代理判断一致:保留原 Turn +结束原因,已交付的正常预算结束不应误报失败。此为源码推演,尚未完成真实模型或取消路径验收。 + +## 本地执行资格已有 owner + +agent/main.py::can_execute 已有 Agent 查询、工具绑定和 AIExecutionRequirement 的组合; +ai/main.py::can_execute 检查本地 dialect、模型/Provider 配置与声明能力,不发远端请求。 +AgentManager.run 则在执行时重新加载 definition、绑定工具并构造 Thread。复用这些行为即可接入 +Job can_handle;无需新增 Sink-owned AI 能力检查或以一次模型调用探测是否可领取。 +can_execute 返回 false 不意味着所有 Peer 都不可执行;不能据此在受理入口拒绝持久化 Job。 diff --git a/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/technical-design.md b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/technical-design.md new file mode 100644 index 00000000..46930ede --- /dev/null +++ b/tasks/knowledge-lifecycle-capabilities/units/agent-query-sink/technical-design.md @@ -0,0 +1,240 @@ +# 技术方案(实施前历史基线) + +本文保留实施前的设计与推演措辞;已由 [core-py#111](https://github.com/InKCre/core-py/pull/111) 实现。 +当前状态见 [packet](packet.md),实现合同见 +[Agent Query Sink TDD](../../../../docs/30-unit-tdd/agent-query-sink.md),验收结果见 +[implementation-evidence](implementation-evidence.md)。下文的设计期状态不代表当前授权或交付状态。 + +产品范围、实例配置、启停与 REST 受理边界见 [D-611–D-620](../../decisions/D611-D620.md),结果和实例 endpoint 见 +[D-621–D-623](../../decisions/D621-D630.md)。 + +## 执行与配置的 owner + +```text +CLI → Agent Query REST ─┐ +通用 Job REST ──────────┴→ JobManager.create → jobs + ↓ claim + JobHandler + ↓ + AgentQuerySink instance ← SinkManager / sinks + │ config.agent + │ ↓ + │ Agent definition → AI model + ↓ + AgentManager / Thread + ↓ 选定的读取工具 + 原子 retrieval / info-base / Resolver + +Agent Query 整理的结果 → Job state → 普通 Job REST → CLI +``` + +JobHandler 适配已领取的 Job,不把检索策略放进 JobManager。AgentQuerySink 拥有初始请求、所选 definition 的 +使用与结果交付;Agent runtime 保持不知道 graph/query 业务。工具直接调用既有能力 owner,不让内部 Agent +通过 MCP/CLI 调用自身,也不使新 sink 依赖整个 Organization 的业务模块。读取工具复用位置还需逐项核验。 + +D-616 确认为 `sink.config = {"agent": 12}`,引用已有 Agent definition;通过 definition.model 选择 AI model。 +model、system prompt、tools、tool choice、每 Turn 模型预算继续归 definition,不在 sink.config 复制完整定义 +或增加同义 model override。多个 Sink 实例自然可以引用不同 Agent,无需另建配置档案或路由框架。 + +## Sink 实例与 Job 的接合 + +D-615 撤回此前不创建实例的建议。实现归 Sink domain,AgentQuerySink 继承 SinkBase;SinkManager 继续拥有 +type 注册、实例持久化、config 与 Peer enable intent。无需为了接入它增加 generic deliver 或新的 lifecycle。 + +Job parameters 引用 Sink ID 与本次检索请求,不复制 Agent/模型配置。一个类型级 JobHandler 按通常方式注册, +不在每个实例 on_start 重复注册相同 Job type。can_handle 核对本地是否运行该 Sink 及是否具备 Agent 执行能力; +领取后交给对应实例执行。需要补的只是 SinkManager 本地运行实例的访问能力,不把 Job 业务移入 SinkManager。 + +D-616 确认 disable 阻止此 Peer 上的新领取,不隐含 abort 已取得实例并开始运行的 Job;停止一次工作仍由 Job +控制。已经启动的调用固定本次 Agent 引用,随后配置修改不改写已有 Thread。启停与 can_handle/claim 之间 +沿用既有 best-effort 边界,不引入跨领域锁或重试;领取后实例已不可用时应明确失败,不绕过 enable 偷启实例。 +资格/领取竞态的具体实现需继续预演;不改变已确认的启停与 Job 独立控制语义。 + +按 D-618,Agent Query 有自己的 REST 受理入口,背后调用 JobManager.create,而非在 HTTP request 内 await +完整 Agent 执行。通用 Job API 可直接提交同类型 Job;两条路径不各自实现校验、排队或执行。 +D-619 确认受理成功返回 202、已创建的 Job 与指向既有 Job GET 的 Location,之后复用 Job 查询/停止及 CLI +有界等待;D-623 确认 endpoint path 与实例挂载方式,见下节。不把“由 Job 执行”当作取消业务 REST 的依据。 +Sink 管理与 Agent definition 管理复用当前 REST;CLI 需要补足相关 Sink 管理,MCP/client-web 仍不在范围内。 + +针对 Sir 的 SinkBase 复核,D-617 确认保留注册、配置和实例管理,不把“拥有 HTTP endpoint”作为实例存在的 +条件。两个 lifecycle hooks 提供默认 no-op,让没有自有常驻资源的 Sink 不必写空 override。 +`on_start(app)` 目前服务真实的 Core FastAPI host,先保留显式参数, +不为消除未使用参数增加 RuntimeContext、HTTP Sink 子层或通用资源框架。MCP 仍覆盖 hooks 管理自身资源; +按 D-623,Agent Query 也覆盖 hooks 管理自己的 endpoint,不另起 worker,也不在 close 取消 Job。 + +## 检索结果合同 + +D-620 已提升产品定位,D-621 确认 answer + references;前者可用 Markdown 回应问题,后者沿用 +`{type, id}` 表达支撑回答/明确交付材料的实体集合,而非全部访问记录。不另建结果实体或 citation ID。 +正文在对应判断附近以 block:42 / relation:73 指明实体身份,让人能对上依据;具体呈现与引用处理在实现 +计划中核对,不预设通用引用解析器或引用关系表。提交与保存方式由 D-622 确认。 + +```json +{ + "answer": "两次讨论的前提不同:前者面向固定能力(block:42),后者面向动态扩展(block:57)。据此判断,两者未必矛盾。", + "references": [ + {"type": "block", "id": 42}, + {"type": "block", "id": 57} + ] +} +``` + +上例是假设案例,不是实际数据或已验收事实。answer 可以是原文的简短指引、比较、解释、条件化判断或 +信息缺口,不固定报告章节;不再强制逐实体 reason 和单独 summary,也不加自报 confidence/混合分数。 +表达中区分原文与推论。完整内容继续通过实体与 Resolver 取得,不默认复制文件或多模态 bytes。 + +明确交付“本次未找到依据”不同于尚未交付任何结果;不能用空对象填补后者。Job 负责执行状态,结果负责 +回应问题。提交与尽力保存路径由 D-622 确认;具体终止映射见 D-627 与下文。 + +## REST 与 CLI 调用 + +D-623 确认 AgentQuerySink 在 on_start 挂载自己实例的 `POST /sinks/{sink_id}/query`,on_close 撤下。 +它不是任意 Sink 的 generic invoke,也不是由 SinkManager 按类型调用同名方法。 +请求包含 `query: str` 与可选 `timeout_seconds`, +前者同时容纳问题、上下文与已知实体线索,暂不机械拆出 context/seeds 等字段;后者映射 Job 执行预算, +不是 HTTP 等待时长。路径决定 Sink,模型/prompt/tools 仍从该 Sink 的 Agent definition 取得。 + +route 经 Agent Query 的共同 admission 路径创建 Job,按 D-619 返回 202/Job/Location。Sink 存在且 +类型正确等共有规则在两种提交入口共同经过的 owner 处实现,不只由专用 route 检查。专用 endpoint +只有实例在该 Peer 启动并挂载时存在;通用 /jobs 独立存在,两者不必具有相同可用性。 +disable 撤下 endpoint,但不取消已受理的 Job,也不影响旧 Job 查询;execute/claim 仍依据执行 Peer +的 Sink 启用与能力。不存在“用了 Job,所以 caller endpoint 必须独立于 Sink 生命周期”的推论。 + +CLI 新增 `inkcre-cli query "问题及上下文" --sink 7`,默认只发起工作并输出 Job;通过既有 +`inkcre-cli job wait --for 30s` 观察、`job get` 读取、`job abort` 停止。复用既有输入文件/stdin、 +schema 发现和 compact JSON 输出能力,不新建 query 的任务控制子命令,也不把有异步结果的 Agent Query +塞进当前同步 recall --mode。Sink 发现/管理命令与初始 Agent 配置见 D-626 和下文。 + +## 结果提交与保存 + +D-622 确认使用 Sink-owned 普通 Agent Tool `submit_query_result`,输入为 D-621 的 answer + references,返回 +同一结构化值。输入由现有 Agent runtime 的 Pydantic model 校验;Sink 从已完整记录的成功 ToolResult +取得值,不再解析最终自由文本,也不重复验证已通过工具边界的结果。该工具不写 graph、不修改 Job, +不要求模型携带 job_id,不引入 per-run context、终结型工具协议或新的 Agent output framework。 + +若有多次成功提交,按 Thread 消息及该批 ToolResult 的既有顺序取最后一次;后续失败提交不抹掉前一次。 +普通 Assistant 文字不成为第二份结果。工具成功表示 Agent 已交付结构化回答,不等于 Job 已完成或 +结果已存入数据库;definition 应说明提交后可以结束,但不为此修改通用 Thread 的终止语义。 + +Job handler 在正常结束及异常/取消清理时,将已记录的回答放入当前 job.state.result;然后正常返回或 +继续传播原错误/取消,由既有 Job close 保存 state 与真实终态。正常结束却没有成功提交应明确报告 +未交付结果,不能填补成空回答。D-627 确认以 termination 保留预算结束原因,有回答不意味着检索穷尽。 + +本轮不提供逐步候选或实时回答发布,也不做持久 Thread、强制补跑总结、隐式重试/恢复。当前 +batch 未闭合、进程崩溃、数据库收尾失败或 expire_overdue 先关闭记录,均可能使内存回答未被持久化。 +这是 D-622 接受的 best-effort 边界,不能宣称“已经调用提交工具就一定不会丢结果”。 + +## 执行基线 + +关键预演已完成,见 [preflight](preflight.md)。D-628 确认四条验收旅程与交付终点;实施次序及横切 +影响见 [实现计划](implementation-plan.md) 和 [Impact Handshake](impact-handshake.md)。新 Query handler +与真实持久化收尾仍需在实施后验收,不把已有 Thread 实验等同于整个功能已通过。 + +不由本拓扑预先推出新数据库表、通用 query framework、持久 Thread 或 MCP/client-web 改动。 + +## 读取工具复用与初始 definition + +D-624 确认初始 Agent 使用六个已有读取工具,加上 D-622 的 submit_query_result;不另建“研究”“总结”工具。 + +| Exact Tool ID | 责任 | +| --- | --- | +| retrieve | lexical/semantic/hybrid 查询;各路保留各自结果 | +| get_entities | 批量读取持久 Block/Relation 记录 | +| resolver | 发现/调用 exact Resolver 的公共 typed read methods | +| get_entity_neighborhood | 读取实体邻域 | +| find_path | 有界路径查询 | +| get_connected_components | 按明确关系条件查询种子连通性 | +| submit_query_result | 提交本次回答与引用,归 Agent Query | + +六个读取工具当前实现在 organization/tools.py。D-629 撤回中央 app/agent_tools 方案:retrieve 与 +get_entities 回归 InfoBaseManager;邻域、路径、连通性回归 GraphNavigationRetrievalManager; +resolver 回归 Resolver 领域,submit_query_result 归 AgentQuerySink。专用 schema 随领域迁移。 +工具注册是调用方式,不成为业务 owner;runtime 只拥有注册、输入绑定与执行,不拥有检索编排。 +单 Block 内容行为由 Resolver 实例执行;现有 resolver 工具的跨类型发现/批量分派建议复用 +ResolverManager,D-630 已确认该分工。不在 Resolver 基类添加 Agent 专用抽象方法。 +Tool handler 是领域所属 controller,Manager/Resolver 方法是 service;不把 handler 整体塞进业务类。 +工具 schema、输入/结果适配在 controller 接合;业务调用保持普通领域接口,runtime 负责工具输入验证。 +bootstrap 显式加载注册;Organization 与 Agent Query 的 definitions 继续引用相同 exact IDs。 +必要常量/类型导出不顺带加载 Organization 实现。语义、参数键和结果行为先保持;去掉读取路径对 +OrganizationError 等写入专用合同的偶然依赖,不能简单搬整个 tools.py 或把它作为共享转发层。 + +初始 definition 作为可编辑的推荐模板提供,创建后是普通 agents 记录;模型由配置者选择。 +模板表达 D-620 的目的、依据与推论区别、可用能力和 D-622 的交付动作,不规定固定检索顺序/工具配额。 +不配 graph 写入、organization 调度或外部研究工具;不在启动时覆盖用户修改。不新增模板 registry, +具体模板交付/CLI 配置步骤见 D-626 的普通管理旅程,不要求用户已经 checkout 仓库。 + +现有 resolver Agent Tool 拒绝 bytes(包括嵌套 bytes),它不是 MCP Resource 或任意多模态交付接口。 +该限制经明确产品复核后由 D-625 接受为本轮边界,不是由历史实现自动推导出的永久产品上限。 + +## 已确认:本轮内容理解边界 + +D-625 确认本轮使用 Resolver 可交付的文本/JSON,以及图中已有或普通 Resolver 读取所 materialize 的文字 +内容;不新增 Query 执行期间将原始图片、音频、视频作为模型输入的通路。媒体 Block 仍可被检索、引用 +并沿图读取 OCR、转录或解释;这不是只检索 text resolver,也不禁止已有读取的 lazy materialization。 +Agent 不能把衍生文字中的缺口当作原始媒体中不存在证据,更不能声称已查看未能取得的原始内容。 + +此取舍避免本轮跨及通用 Agent/AI 消息合同和 dialect 投影。以真实媒体案例检查工具是否能取得衍生 +正文,以及没有可用正文时能否准确说明缺口;需要原始媒体理解的需求应保留为已知能力边界,而非伪装 +成已经回答或系统中没有相关信息。 + +## 已确认:通过普通 CLI 管理完成配置 + +D-626 确认无需本地 Core checkout。Agent Query 的使用文档提供完整、可复制编辑的 AgentForm JSON,包含推荐 +prompt、七个 exact tools、tool_choice 与可调整的模型调用预算;配置者选择现有 AIModel 并填写 model。 +文档是推荐模板的单一交付位置,不为它新增模板 REST API、registry、安装副作用或 CLI 内置 prompt。 +模板描述信息需求、依据与推论的区别、D-625 内容边界及 submit_query_result,不规定固定检索步骤。 + +已有 ai models、agent tools、agent create/update 和 connection 命令可复用。补齐普通 sink 命令组: +types、list、get、create、config get/replace、enable、disable、delete。create --type 与 config replace +使用既有 --input/--input-json/--schema;动态 config schema 来自 sink-types catalog,不在 CLI 硬编码。 +这些操作映射既有 Sink REST,不引入 query setup 向导或把 Agent、Sink 创建合并为一个事务。 +enable/disable 作用于当前连接的 Peer,不增加新的 delegation 或跨 Peer 默认选择行为。 + +文档旅程是:发现模型/工具 → 将模板保存为本地 JSON、选择 model → agent create → sink create +(config.agent 引用返回的 Agent ID)→ sink enable → query --sink → job wait/get。每步使用实际响应 ID, +不自动选择唯一模型、默认 Agent 或默认 Sink。已具备 AIProvider/AIModel 和基础检索配置是此旅程的前提; +本轮不借初始化 Agent Query 扩展成部署向导。模板预算初值随真实模型验收校准,不成为协议常量。 + +当前 Sink REST 列表返回完整 tuple,CLI 尚无 sink group;具体分页与 schema 发现对齐需在实现计划中 +核对,不声称现有接口已经具备 Source REST 的所有能力。Sink nickname 目前仅创建时可填写,本轮不为 +上述配置旅程额外设计通用实例 PATCH。 + +## 已确认:结束原因与 Job 终态 + +D-627 确认保持 Job 通用状态不变,由 Agent Query 判断是否完成自己的交付合同。Turn 正常返回时,将其原有 +completed/max_model_calls 值记录为 job.state.termination;存在成功提交则 handler 正常返回,由 +JobManager 关闭为 finished。不因为触及模型调用预算就判失败:最后允许的一次模型调用可以恰好完成 +submit_query_result,而 Thread 此时仍返回 max_model_calls。finished 表示正常结束并已交付,不表示 +检索穷尽或答案充分;预算结束原因继续可见,不能悄悄改成 completed。 + +| 执行结果 | Job 状态 | Agent Query state | +| --- | --- | --- | +| completed,已有提交 | finished | result + termination=completed | +| max_model_calls,已有提交 | finished | result + termination=max_model_calls | +| 上述任一种正常返回,但没有提交 | failed | termination + 既有 error 字段说明未交付;无 result | +| 执行异常、Job 超时或取消 | failed / timed_out / aborted | 按 D-622 尽力保留已有 result;沿用 Job 错误/终态合同 | + +无提交不是“未找到信息”。Agent 可以提交说明缺少依据、references 为空的回答;这依然是交付。 +普通 Assistant 最终文字不能替代 submit_query_result,也不由 Sink 解析或自动包装成结果。 +无提交时在 Query owner 报告未交付错误,使用现有 Job 异常收尾,不扩充 JobManager 的业务分支。 +已有结果后发生真实执行异常也不能改为 finished;结果与失败状态可以同时存在。 + +不增加 partial/success/has_result 字段或新的 Job 状态,不强制补跑总结。模型调用次数预算与 Job +wall-clock timeout 不混用。Thread 未正常返回时不虚构 termination;Job 已有状态足以表达中止原因。 +后续预演覆盖最后一轮提交、预算结束无提交、已提交后异常及取消传播,不在设计阶段新增自动化测试。 + +预算检查是 Python runtime 的行为,而非模型的行为。本轮不把剩余次数、倒计时或最大调用数注入 +system/user message,不增加预算查询工具,也不临近预算时向模型发送收尾消息。配置中的执行预算 +继续由 Thread 消费,termination 供调用者观察,不回灌至模型。模板要求提交回答,与告知剩余预算不同。 + +## 执行资格:复用 Agent/AI 的本地判断 + +按 D-616 的接合,在 claim 前先取得本地运行中的对应 AgentQuerySink,再调用现有 +AgentManager.can_execute(config.agent, "text")。该方法检查 Agent 是否存在、本地能否绑定其工具, +再交给 AIManager.can_execute 判断模型、Provider、dialect 和所需能力。它不是实际模型请求或远端 +健康探测,不需要把同一判断复制进 Sink,也不新增资格注册表。 + +不预执行检索,也不要求全部索引完备或所有候选 Resolver 都可读取才能领取;这些属于实际工具执行 +及证据判断。领取后按现有 run(agent_id, initial_message) 绑定当时的 definition,保留现有一次快照 +语义;can_handle 不是资源预留,检查后变化沿既有失败处理,不重试或静默换模型/Sink。 +运行实例访问与启停交错仍在 preflight 范围,不据此增加独立 Worker 或扩大 SinkManager 职责。 diff --git a/tests/organization/acceptance/test_black_box.py b/tests/organization/acceptance/test_black_box.py index 08880485..19f8b303 100644 --- a/tests/organization/acceptance/test_black_box.py +++ b/tests/organization/acceptance/test_black_box.py @@ -17,7 +17,14 @@ from app.business.ai import AIManager from app.business.graph_navigation_retrieval import GraphNavigationRetrievalManager +from app.business.graph_navigation_retrieval.tools import ( + FIND_PATH_TOOL, + GET_CONNECTED_COMPONENTS_TOOL, + GET_ENTITY_NEIGHBORHOOD_TOOL, +) +from app.business.info_base.tools import GET_ENTITIES_TOOL, RETRIEVE_TOOL from app.business.info_base.resolver import ResolverManager, register_core_resolvers +from app.business.info_base.resolver.tools import RESOLVER_TOOL from app.business.job import JobManager from app.business.lexical_retrieval import LexicalRetrievalManager from app.business.organization import ( @@ -25,16 +32,10 @@ CREATE_SYNTHESIS_TOOL, DRAFT_GRAPH_TOOL, GET_DRAFT_GRAPH_SCHEMA_TOOL, - GET_ENTITIES_TOOL, - GET_ENTITY_NEIGHBORHOOD_TOOL, - FIND_PATH_TOOL, - GET_CONNECTED_COMPONENTS_TOOL, RECORD_DUPLICATE_ASSERTION_TOOL, RECORD_EVIDENCE_STANCE_TOOL, RECORD_REFINEMENT_TOOL, RECORD_SUPERSESSION_TOOL, - RESOLVER_TOOL, - RETRIEVE_TOOL, SUBMIT_GRAPH_TOOL, register_core_organization_behaviors, )