This commit is contained in:
1 parent
34f4923566
commit
146aa3a281
3 files changed
+98
-10
No files matched your search
+1
-1
Submodule agents/gewu updated: 65bc8a9422...5a6cd7af4f.
@@ -1285,10 +1285,10 @@ def create_mcp_server(
|
|||||||
|
|
||||||
@server.tool(name="proposal_submit", structured_output=False)
|
@server.tool(name="proposal_submit", structured_output=False)
|
||||||
def proposal_submit(proposal_json: str, ctx: Context) -> str:
|
def proposal_submit(proposal_json: str, ctx: Context) -> str:
|
||||||
"""Validate and submit one bounded typed proposal revision."""
|
"""Validate and submit one bounded proposal; report any safe input repair."""
|
||||||
|
|
||||||
principal = _principal(ctx, identity_provider)
|
principal = _principal(ctx, identity_provider)
|
||||||
proposal = _parse_proposal(proposal_json)
|
proposal, input_repaired = _parse_proposal(proposal_json)
|
||||||
context = context_provider()
|
context = context_provider()
|
||||||
_authorize_proposal(principal, proposal, context)
|
_authorize_proposal(principal, proposal, context)
|
||||||
staged_extractions: dict[str, ExtractionRecord] = {}
|
staged_extractions: dict[str, ExtractionRecord] = {}
|
||||||
@@ -1321,6 +1321,7 @@ def create_mcp_server(
|
|||||||
"status": "accepted" if outcome.receipt else outcome.validation.status,
|
"status": "accepted" if outcome.receipt else outcome.validation.status,
|
||||||
"validation_status": outcome.validation.status,
|
"validation_status": outcome.validation.status,
|
||||||
"eligible_for_auto_accept": outcome.validation.eligible_for_auto_accept,
|
"eligible_for_auto_accept": outcome.validation.eligible_for_auto_accept,
|
||||||
|
"input_repaired": input_repaired,
|
||||||
"findings": [
|
"findings": [
|
||||||
finding.model_dump(mode="json") for finding in outcome.validation.findings
|
finding.model_dump(mode="json") for finding in outcome.validation.findings
|
||||||
],
|
],
|
||||||
@@ -1339,8 +1340,20 @@ def create_mcp_server(
|
|||||||
raise ValueError("Proposal exceeds the supported request size")
|
raise ValueError("Proposal exceeds the supported request size")
|
||||||
try:
|
try:
|
||||||
draft = json.loads(proposal_json, object_pairs_hook=_reject_duplicate_keys)
|
draft = json.loads(proposal_json, object_pairs_hook=_reject_duplicate_keys)
|
||||||
except (ValueError, TypeError) as error:
|
except (ValueError, TypeError):
|
||||||
raise ValueError("Proposal draft must be valid JSON") from error
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"status": "invalid",
|
||||||
|
"issues": [
|
||||||
|
{
|
||||||
|
"path": "",
|
||||||
|
"code": "invalid_proposal_draft",
|
||||||
|
"message": "Proposal draft must be valid JSON",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
if not isinstance(draft, dict):
|
if not isinstance(draft, dict):
|
||||||
raise ValueError("Proposal draft must be a JSON object")
|
raise ValueError("Proposal draft must be a JSON object")
|
||||||
try:
|
try:
|
||||||
@@ -1378,10 +1391,36 @@ def create_mcp_server(
|
|||||||
|
|
||||||
@server.tool(name="proposal_evaluate", structured_output=False)
|
@server.tool(name="proposal_evaluate", structured_output=False)
|
||||||
def proposal_evaluate(proposal_json: str, ctx: Context) -> str:
|
def proposal_evaluate(proposal_json: str, ctx: Context) -> str:
|
||||||
"""Evaluate one bounded proposal without storing or publishing it."""
|
"""Evaluate one bounded proposal without storing or publishing it.
|
||||||
|
|
||||||
|
A single missing final outer brace is repaired only if the result
|
||||||
|
is valid JSON and matches the full contract. The response exposes
|
||||||
|
input_repaired=true. Other malformed JSON returns status=invalid.
|
||||||
|
A valid proposal may instead return needs-review when an applicable
|
||||||
|
policy requires an owner decision.
|
||||||
|
"""
|
||||||
|
|
||||||
principal = _principal(ctx, identity_provider)
|
principal = _principal(ctx, identity_provider)
|
||||||
proposal = _parse_proposal(proposal_json)
|
try:
|
||||||
|
proposal, input_repaired = _parse_proposal(proposal_json)
|
||||||
|
except ValueError:
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"proposal_id": None,
|
||||||
|
"revision": None,
|
||||||
|
"status": "invalid",
|
||||||
|
"eligible_for_auto_accept": False,
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"code": "PROPOSAL_JSON_INVALID",
|
||||||
|
"message": "Proposal does not match the GEWU v1 contract",
|
||||||
|
"path": "proposal_json",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"affected_policy_ids": [],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
context = context_provider()
|
context = context_provider()
|
||||||
_authorize_proposal(principal, proposal, context)
|
_authorize_proposal(principal, proposal, context)
|
||||||
staged_extractions: dict[str, ExtractionRecord] = {}
|
staged_extractions: dict[str, ExtractionRecord] = {}
|
||||||
@@ -1408,6 +1447,7 @@ def create_mcp_server(
|
|||||||
"revision": proposal.revision,
|
"revision": proposal.revision,
|
||||||
"status": report.status,
|
"status": report.status,
|
||||||
"eligible_for_auto_accept": report.eligible_for_auto_accept,
|
"eligible_for_auto_accept": report.eligible_for_auto_accept,
|
||||||
|
"input_repaired": input_repaired,
|
||||||
"findings": [finding.model_dump(mode="json") for finding in report.findings],
|
"findings": [finding.model_dump(mode="json") for finding in report.findings],
|
||||||
"affected_policy_ids": report.affected_policy_ids,
|
"affected_policy_ids": report.affected_policy_ids,
|
||||||
},
|
},
|
||||||
@@ -1673,17 +1713,32 @@ def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _parse_proposal(proposal_json: str) -> ProposalRevision:
|
def _parse_proposal(proposal_json: str) -> tuple[ProposalRevision, bool]:
|
||||||
if len(proposal_json.encode("utf-8")) > 1_000_000:
|
if len(proposal_json.encode("utf-8")) > 1_000_000:
|
||||||
raise ValueError("Proposal exceeds the supported request size")
|
raise ValueError("Proposal exceeds the supported request size")
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(proposal_json, object_pairs_hook=_reject_duplicate_keys)
|
parsed = json.loads(proposal_json, object_pairs_hook=_reject_duplicate_keys)
|
||||||
proposal = ProposalRevision.model_validate_json(proposal_json, strict=True)
|
proposal = ProposalRevision.model_validate_json(proposal_json, strict=True)
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
# Tool-call argument serialization can drop the final outer brace while
|
||||||
|
# copying a prepared JSON string. Accept only this unambiguous one-byte
|
||||||
|
# truncation; nested truncation, duplicate keys and schema errors remain
|
||||||
|
# invalid. The response exposes the repair so the caller can report it.
|
||||||
|
candidate = proposal_json.rstrip()
|
||||||
|
if error.pos != len(candidate) or not candidate.startswith("{"):
|
||||||
|
raise ValueError("Proposal does not match the GEWU v1 contract") from error
|
||||||
|
repaired_json = candidate + "}"
|
||||||
|
try:
|
||||||
|
parsed = json.loads(repaired_json, object_pairs_hook=_reject_duplicate_keys)
|
||||||
|
proposal = ProposalRevision.model_validate_json(repaired_json, strict=True)
|
||||||
|
except (ValueError, ValidationError) as repaired_error:
|
||||||
|
raise ValueError("Proposal does not match the GEWU v1 contract") from repaired_error
|
||||||
|
return proposal, True
|
||||||
except (ValueError, ValidationError) as error:
|
except (ValueError, ValidationError) as error:
|
||||||
raise ValueError("Proposal does not match the GEWU v1 contract") from error
|
raise ValueError("Proposal does not match the GEWU v1 contract") from error
|
||||||
if not isinstance(parsed, dict):
|
if not isinstance(parsed, dict):
|
||||||
raise ValueError("Proposal must be a JSON object")
|
raise ValueError("Proposal must be a JSON object")
|
||||||
return proposal
|
return proposal, False
|
||||||
|
|
||||||
|
|
||||||
def _parse_run_request[RequestT: (RunCreateRequest, RunCheckpointRequest)](
|
def _parse_run_request[RequestT: (RunCreateRequest, RunCheckpointRequest)](
|
||||||
|
|||||||
@@ -252,6 +252,38 @@ def test_mcp_source_to_citation_vertical_slice(tmp_path: Path) -> None:
|
|||||||
assert prepared_proposal.payload_digest == proposal_payload_digest(
|
assert prepared_proposal.payload_digest == proposal_payload_digest(
|
||||||
prepared_proposal
|
prepared_proposal
|
||||||
)
|
)
|
||||||
|
truncated_evaluation = await mcp.call_tool(
|
||||||
|
"proposal_evaluate",
|
||||||
|
{"proposal_json": prepared_payload["proposal_json"][:-1]},
|
||||||
|
)
|
||||||
|
assert not truncated_evaluation.is_error, truncated_evaluation.content
|
||||||
|
truncated_evaluation_payload = json.loads(
|
||||||
|
_text_payload(truncated_evaluation)
|
||||||
|
)
|
||||||
|
assert truncated_evaluation_payload["proposal_id"] == (
|
||||||
|
prepared_proposal.proposal_id
|
||||||
|
)
|
||||||
|
assert truncated_evaluation_payload["input_repaired"] is True
|
||||||
|
malformed_prepare = await mcp.call_tool(
|
||||||
|
"proposal_prepare", {"proposal_json": '{"schema_version":'}
|
||||||
|
)
|
||||||
|
assert not malformed_prepare.is_error, malformed_prepare.content
|
||||||
|
malformed_prepare_payload = json.loads(_text_payload(malformed_prepare))
|
||||||
|
assert malformed_prepare_payload["status"] == "invalid"
|
||||||
|
assert malformed_prepare_payload["issues"][0]["code"] == (
|
||||||
|
"invalid_proposal_draft"
|
||||||
|
)
|
||||||
|
malformed_evaluation = await mcp.call_tool(
|
||||||
|
"proposal_evaluate", {"proposal_json": '{"schema_version":'}
|
||||||
|
)
|
||||||
|
assert not malformed_evaluation.is_error, malformed_evaluation.content
|
||||||
|
malformed_evaluation_payload = json.loads(
|
||||||
|
_text_payload(malformed_evaluation)
|
||||||
|
)
|
||||||
|
assert malformed_evaluation_payload["status"] == "invalid"
|
||||||
|
assert malformed_evaluation_payload["findings"][0]["code"] == (
|
||||||
|
"PROPOSAL_JSON_INVALID"
|
||||||
|
)
|
||||||
invalid_draft = json.loads(json.dumps(draft))
|
invalid_draft = json.loads(json.dumps(draft))
|
||||||
invalid_evidence = invalid_draft["operations"][1]["node"]["claims"][0][
|
invalid_evidence = invalid_draft["operations"][1]["node"]["claims"][0][
|
||||||
"evidence"
|
"evidence"
|
||||||
@@ -928,12 +960,13 @@ def test_mcp_source_to_citation_vertical_slice(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
submitted = await mcp.call_tool(
|
submitted = await mcp.call_tool(
|
||||||
"proposal_submit",
|
"proposal_submit",
|
||||||
{"proposal_json": candidate.model_dump_json()},
|
{"proposal_json": candidate.model_dump_json()[:-1]},
|
||||||
)
|
)
|
||||||
assert not submitted.is_error, submitted.content
|
assert not submitted.is_error, submitted.content
|
||||||
outcome = json.loads(_text_payload(submitted))
|
outcome = json.loads(_text_payload(submitted))
|
||||||
assert outcome["status"] == "accepted"
|
assert outcome["status"] == "accepted"
|
||||||
assert outcome["validation_status"] == "eligible"
|
assert outcome["validation_status"] == "eligible"
|
||||||
|
assert outcome["input_repaired"] is True
|
||||||
assert outcome["receipt"] is not None
|
assert outcome["receipt"] is not None
|
||||||
accepted_head = repository.head()
|
accepted_head = repository.head()
|
||||||
retried = await mcp.call_tool(
|
retried = await mcp.call_tool(
|
||||||
|
|||||||
Reference in new issue
Block a user