diff --git a/MEMORY.md b/MEMORY.md index d366761..047e41c 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -174,3 +174,7 @@ - HTTP 확장4(ADR-0084, 구현·로컬 검증 완료): run/resume의 연결별 bearer env override를 별도 map으로 받으며 저장된 연결 인증을 바꾸지 않는다. 참조만 manifest/profile에 결합하고 누락·변경·값 부재/invalid에서 fallback 없이 거절한다. shipment/sensor의 다른 토큰·연결 불변·상태/모델 비노출·토큰 회전·완료 replay와 echo 차단을 loopback에서 확인했다. 서버의 사용자 인증·대리 권한·Run 접근 제어·process/state 격리는 외부 호스트 책임이며 실제 통합은 미검증이다. - HTTP 확장4 검증: Rust784개(4 ignored), pinned backend+컴파일 CLI Python153개(7 skipped), Clippy·fmt·protocol·문서·workflow 검사 통과. 한 Run의 연결2개/서로 다른 토큰도 올바르게 전달됐고 모든 상태 파일·모델 요청에 값이 없었다. 참조만 profile에 결합하며 override 없는 기존 binding은 유지했다. 승인 있는 쓰기는 아직 구현하지 않았다. - HTTP 확장4 변경 추적: [PR #142](https://github.com/PlateerLab/xgen-cli/pull/142), 구현 코드 `67d1d40`. PR의 CI 결과와 merge 상태를 정본으로 참조한다. + +- 다음 범용 작업(사용자 승인, 2026-10-31): 명세별 HTTP 지원 범위를 한번에 보는 `tools check-http` 진단을 진행한다. 개별 describe/execution의 classifier를 재사용하고 저장된 조회 지정 정책을 적용한다. 판정 범위는 import된 도구이며 실제 API·인증·모델 검증과 구분한다. +- HTTP 지원 보고서(ADR-0085, 구현·로컬 검증 완료): 전체 지원/거절·제외 이유 집계, 이름순 bounded page, artifact/policy digest를 제공한다. assets/calendar 설계와 shipment/sensor 실제 CLI 대조에서 개별 describe와 일치했고, bearer 환경변수가 없어도 API·모델 호출0회였다. 외부 통합 과업 수는 계속 미확정이다. +- HTTP 지원 보고서 검증: Rust784개(4 ignored), pinned backend+컴파일 CLI Python159개(7 skipped), Clippy·fmt·protocol·문서·workflow 검사 통과. 독립 diff 검토의 policy 전달·pagination 가설은 실제 native 정책 주입·행 크기 상한·마지막 페이지 검사와 대조했다. Run 계약/profile·HTTP 지원 규칙은 바꾸지 않았다. diff --git a/crates/xgen-cli/src/tool_graph_worker.py b/crates/xgen-cli/src/tool_graph_worker.py index 458b8f0..d0a1785 100644 --- a/crates/xgen-cli/src/tool_graph_worker.py +++ b/crates/xgen-cli/src/tool_graph_worker.py @@ -334,6 +334,9 @@ def dispatch(request): raise WorkerError("http_read_operation_invalid") return {"ok": True} graph = graph_from_artifact(envelope["artifact"]) + if operation == "check_http": + return http_read_report(envelope, [tool.to_dict() for tool in graph.tools.values()], + request.get("http_policy"), request.get("offset", 0), request.get("limit", 100)) if operation in ("describe", "describe_view", "execution_contract"): tool = graph.tools.get(request["tool"]) if tool is None: @@ -646,6 +649,58 @@ def http_read_contract(artifact, tool, policy=None): return {"supported":False,"error":"http_original_contract_invalid"} +def http_read_report(envelope, tools, policy, offset, limit): + # Diagnostic projection of the same contract classifier used by describe and execution. + if type(offset) is not int or type(limit) is not int or offset < 0 or not 1 <= limit <= 500: + raise WorkerError("invalid_http_report_page") + tools = sorted(tools, key=lambda tool: tool["name"]) + if offset > len(tools): + raise WorkerError("invalid_http_report_page") + supported, excluded_count, reasons, rows = 0, 0, collections.Counter(), [] + excluded_reasons = collections.Counter() + end, page_bytes, page_full = offset, 0, False + for index, tool in enumerate(tools): + result = http_read_contract(envelope["artifact"], tool, policy) + supported += int(result["supported"]) + excluded = result.get("contract", {}).get("excluded_parameters", []) + excluded_count += len(excluded) + excluded_reasons.update(item["reason"] for item in excluded) + if not result["supported"]: + reasons[result["error"]] += 1 + if index < offset or page_full: + continue + metadata = tool.get("metadata", {}) + operation_id = None + try: + source = envelope["artifact"]["metadata"]["xgen_http_sources"][metadata["source_url"]] + operation = dereference(source["paths"][metadata["path"]], source, recursive=False)[metadata["method"]] + operation_id = operation.get("operationId") + except (KeyError, TypeError, AttributeError, WorkerError): + pass + row = {"tool": preview(tool["name"], 1024), "operation_id": preview(operation_id, 128) if isinstance(operation_id, str) else None, + "method": preview(metadata.get("method"), 16), "path": preview(metadata.get("path"), 1024), + "supported": result["supported"]} + if result["supported"]: + row.update({"contract_digest": result["contract_digest"], "excluded_parameter_count": len(excluded), + "excluded_parameters": [{"location": item["location"], "name": preview(item["name"], 256), "reason": item["reason"]} for item in excluded[:32]], + "excluded_parameters_complete": len(excluded) <= 32 and all(len(item["name"].encode()) <= 256 for item in excluded)}) + else: + row["error"] = result["error"] + size = len(canonical(row)) + if len(rows) >= limit or page_bytes + size > 1024 * 1024: + page_full = True + continue + rows.append(row) + page_bytes += size + end = index + 1 + return {"ok": True, **summarize(envelope), "report_version": 1, "scope": "imported_tools", "policy_digest": digest(policy), + "connection_present": policy is not None, "credentials_checked": False, "network_checked": False, + "summary": {"total": len(tools), "supported": supported, "unsupported": len(tools) - supported, + "excluded_parameter_count": excluded_count, "unsupported_reasons": dict(sorted(reasons.items())), + "excluded_parameter_reasons": dict(sorted(excluded_reasons.items()))}, + "page": {"offset": offset, "limit": limit, "next_offset": end if end < len(tools) else None}, "tools": rows} + + def main(): warnings.simplefilter("ignore") try: diff --git a/crates/xgen-cli/src/tools.rs b/crates/xgen-cli/src/tools.rs index 402279d..07573cd 100644 --- a/crates/xgen-cli/src/tools.rs +++ b/crates/xgen-cli/src/tools.rs @@ -81,6 +81,15 @@ pub enum ToolsCommand { #[arg(long, default_value_t = 5, value_parser = clap::value_parser!(u8).range(1..=20))] top_k: u8, }, + /// Inspect HTTP contract support for a saved collection without API or credential access. + CheckHttp { + #[arg(long)] + name: String, + #[arg(long, default_value_t = 0)] + offset: u32, + #[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u16).range(1..=500))] + limit: u16, + }, /// Read the normalized schema and retained API contract of an exact saved tool. Describe { #[arg(long)] @@ -203,6 +212,13 @@ fn request(command: ToolsCommand) -> Result { } json!({"operation": "search", "name": name, "query": query, "top_k": top_k}) } + ToolsCommand::CheckHttp { + name, + offset, + limit, + } => { + json!({"operation":"check_http", "name":name, "offset":offset, "limit":limit}) + } ToolsCommand::Describe { name, tool, @@ -230,7 +246,7 @@ fn request(command: ToolsCommand) -> Result { value["root"] = json!(root.to_str().ok_or("invalid_state_home")?); if matches!( value["operation"].as_str(), - Some("describe" | "describe_view") + Some("describe" | "describe_view" | "check_http") ) { let snapshots = crate::graph_discovery::DiscoveryCatalog::discover() .map_err(|()| "http_catalog_unavailable")?; diff --git a/docs/adr/0085-http-read-support-report.md b/docs/adr/0085-http-read-support-report.md new file mode 100644 index 0000000..afdbf4e --- /dev/null +++ b/docs/adr/0085-http-read-support-report.md @@ -0,0 +1,50 @@ +# ADR-0085: import된 도구의 HTTP 조회 지원 보고서 + +- 날짜: 2026-10-31 +- 상태: 채택, 구현·로컬 검증 완료 +- 범위: 일반 CLI 진단. API 호출 권한이나 실행 계약은 추가하지 않는다. + +## 결정 + +`xgen tools check-http --name COLLECTION [--offset N] [--limit N]`을 추가한다. +CLI의 기존 연결 정책 로딩 경로를 재사용하고 worker의 기존 http_read_contract를 각 도구에 적용한다. +개별 describe·실행 계약과 다른 지원 판정기를 만들지 않는다. GET 지정과 read_operations 목록을 +반영하되 인증값·OS secret store를 읽거나 API·모델을 호출하지 않는다. + +전체 summary에는 import된 도구 수, supported/unsupported 수, 거절 이유별 operation 수, +제외한 선택 파라미터 수·이유별 개수를 제공한다. unsupported가 있어도 보고서 생성 성공이면 exit0이다. +저장소·snapshot·페이지 요청 오류는 기존 CLI 오류 경로로 실패한다. + +행에는 tool·원본 operationId·method·path, 지원 여부, 지원 계약 digest 또는 거절 코드, +선택 파라미터 제외 이름·위치·이유를 제공한다. 원본 operationId는 보존된 명세에서 조회하며 +정규화된 도구 이름을 operationId로 추측하지 않는다. 긴 문자열은 기존 preview(text/omitted) 형식으로 +명시적으로 자른다. schema·응답 예시·인증 참조·토큰 값은 보고서에 넣지 않는다. + +scope는 imported_tools다. graph-tool-call import에서 누락된 원본 operation까지 센 결과가 아니다. +연결이 없을 때는 기존 describe와 같은 GET 계약 판정이며 실제 호출 승인을 뜻하지 않는다. +connection_present·credentials_checked=false·network_checked=false·execution_enabled=false로 +진단의 경계를 명시한다. 지원 판정은 credential 유효성·입력값 검증·API 건강 상태·업무 성공 보장이 아니다. + +## 크기와 일관성 + +도구 이름으로 정렬하며 offset/limit을 제공한다(기본100개, 최대500개). 한 페이지의 행 합계는1MiB, +제외 항목은 도구당32개·이름256 bytes로 제한한다. 도구 이름/경로1024 bytes, operationId128 bytes, +method16 bytes의 preview를 사용한다. 제외 목록의 count/complete를 함께 보낸다. + +summary는 페이지 밖 도구까지 모두 집계한다. next_offset은 실제 반환한 행 다음을 가리키므로 +행 개수 제한보다 byte 제한에 먼저 닿아도 건너뛰지 않는다. 마지막 offset==total 요청은 빈 페이지와 +전체 summary를 반환한다. 페이지를 합치는 호출자는 artifact_digest와 policy_digest가 같은지 확인한다. +policy_digest는 인증 참조가 없는 GET/operation 지정 map 또는 미연결 null만 해시한다. + +이 명령은 모델 Capability가 아니므로 Capability 버전·Run manifest·execution profile을 바꾸지 않는다. + +## 검증 + +assets(OpenAPI3)·calendar(Swagger2) 설계 fixture에서 개별 classifier와 summary·페이지 결과를 +대조한다. POST 지정 전후·선택 파라미터 제외·빈 목록·잘못된 페이지·마지막 빈 페이지·긴 제외 이름과 +1MiB 페이지 경계를 검증한다. shipment/sensor 보류 fixture의 실제 import→connect→check-http를 +컴파일 CLI와 pinned backend로 검증한다. bearer 환경변수가 없어도 점검되며 API/모델 호출은0회다. + +실제 외부 명세의 실행 가능 과업 수, LLM 완료율, API 응답·인증의 유효성은 측정하지 않는다. + +Rust784개(4 ignored), pinned backend+컴파일 CLI Python159개(7 skipped), Clippy·fmt·protocol·문서·workflow 검사를 통과했다. diff --git a/docs/development/http-read-tools.md b/docs/development/http-read-tools.md index 266be2f..d314d08 100644 --- a/docs/development/http-read-tools.md +++ b/docs/development/http-read-tools.md @@ -37,6 +37,28 @@ xgen tools remove --name my-system # collection, 연결, 저장된 토큰 - 연결 저장소가 사용자 전용이 아니거나, 연결 파일이 symlink거나, 저장소를 읽을 수 없으면(권한 오류를 "연결 없음"으로 보지 않는다) 아무것도 지우지 않고 실패한다. - 연결을 바꾸면 그 연결로 시작한 Run은 재개 시 기존처럼 거절된다. +## HTTP 지원 범위 점검 + +```bash +xgen tools check-http --name my-system +xgen tools check-http --name my-system --offset 0 --limit 100 +``` + +개별 `describe`와 같은 판정으로 import된 도구의 지원·거절·선택 파라미터 제외를 JSON으로 모은다. +`summary`는 전체 집계이고 `tools`는 이름순 페이지다. 제외 파라미터 수는 지원된 계약에서 실제로 제외한 항목만 센다. 다음 페이지는 반환된 `page.next_offset`을 +`--offset`으로 넣는다. 값이 null이면 끝이다. 기본100개·최대500개이며 행 byte 한도에 먼저 닿으면 +더 적게 반환할 수 있다. 페이지를 합칠 때 `artifact_digest`와 `policy_digest`가 같은지 확인한다. + +연결이 있으면 저장된 GET/POST 조회 지정 정책을 반영한다. 연결이 없으면 기존 describe와 같은 +GET 계약 판정이며 `connection_present=false`다. tool·원본 operationId·method·path는 +`text`/`omitted` preview이므로 잘린 이름으로 operation 지정을 시도하지 않는다. +제외한 파라미터는 count·complete와 함께 이름·위치·이유를 제공한다. + +이 명령은 토큰을 읽거나 API·모델을 호출하지 않는다. `supported`는 **계약 판정**이며 인증·실제 입력값· +API 응답의 검증이나 과업 성공을 뜻하지 않는다. `scope=imported_tools`이므로 import에서 빠진 원본 +operation의 수까지 확인한 결과도 아니다. unsupported 도구가 있어도 보고서 생성에 성공하면 exit0이다. +[ADR-0085](../adr/0085-http-read-support-report.md). + ## Run별 bearer 주입 ```bash diff --git a/docs/development/tool-integration-todo.md b/docs/development/tool-integration-todo.md index 868e959..2f001d5 100644 --- a/docs/development/tool-integration-todo.md +++ b/docs/development/tool-integration-todo.md @@ -149,3 +149,10 @@ [ADR-0084](../adr/0084-run-bearer-overrides.md), [PR #142](https://github.com/PlateerLab/xgen-cli/pull/142). 5. **별도 설계, 대회 후:** 승인 있는 POST/PUT/PATCH/DELETE 쓰기·host Idempotency-Key·NonIdempotent Unknown의 no-retry. 1~4 완료 뒤 진행하며 이번 조회 확장에 포함하지 않는다. + +### HTTP 지원 범위 점검 (2026-10-31) + +- **구현·로컬 검증 완료:** `tools check-http --name COLLECTION`으로 import된 도구의 지원·거절·선택 파라미터 제외를 집계한다. +- 기존 classifier·저장된 조회 정책을 재사용하며 API·모델·credential 조회는 하지 않는다. 실제 실행 가능 과업 수와 구분한다. +- 원본 operation이 import에서 누락됐는지와 실제 인증·API 응답·기존 에이전트 비교는 별도 통합 검증이다. +- [ADR-0085](../adr/0085-http-read-support-report.md), [사용법](http-read-tools.md#http-지원-범위-점검). diff --git a/scripts/tests/test_http_read_report.py b/scripts/tests/test_http_read_report.py new file mode 100644 index 0000000..31c1c12 --- /dev/null +++ b/scripts/tests/test_http_read_report.py @@ -0,0 +1,145 @@ +"""Offline HTTP support report agrees with individual describe contracts.""" +import copy +import importlib.util +import json +import os +import unittest + +import test_tool_graph_worker as base +from test_tool_graph_worker import WORKER, asset_spec, calendar_spec +import test_http_read_tools as http + + +def envelope(source): + return {"collection":"fixture","artifact_digest":"a"*64,"artifact":{"tools":{}, + "metadata":{"source_count":1,"xgen_http_sources":{"fixture":source}}}} + + +def tools_for(source): + return [{"name":operation["operationId"],"metadata":{"source_url":"fixture","path":path,"method":method}} + for path,item in source["paths"].items() for method,operation in item.items() + if method in ("get","post","delete")] + + +class ReportTests(unittest.TestCase): + def test_two_contract_formats_match_individual_classifier_and_keep_summary_across_pages(self): + for source,path in ((asset_spec(),"/assets"),(calendar_spec(),"/events")): + with self.subTest(path=path): + operation=source["paths"][path]["get"] + operation["parameters"]=[{"name":"trace","in":"header","schema":{"type":"string"}}] + post=copy.deepcopy(operation);post["operationId"]="inspectRecords" + source["paths"][path]["post"]=post + tools=tools_for(source);saved=envelope(source) + policy={"allow_get":True,"read_operations":[]} + first=WORKER.http_read_report(saved,tools,policy,0,1) + self.assertEqual(first["summary"]["supported"],2) + self.assertEqual(first["summary"]["unsupported"],1) + self.assertEqual(first["summary"]["excluded_parameter_count"],1) + self.assertFalse(first["execution_enabled"]) + rows=[];offset=0 + while offset is not None: + page=WORKER.http_read_report(saved,tools,policy,offset,1) + self.assertEqual(page["summary"],first["summary"]) + rows+=page["tools"];offset=page["page"]["next_offset"] + self.assertEqual([row["tool"]["text"] for row in rows],sorted(tool["name"] for tool in tools)) + terminal=WORKER.http_read_report(saved,tools,policy,len(tools),1) + self.assertEqual(terminal["tools"],[]) + self.assertIsNone(terminal["page"]["next_offset"]) + self.assertEqual(terminal["summary"],first["summary"]) + for row in rows: + tool=next(tool for tool in tools if tool["name"]==row["tool"]["text"]) + original=WORKER.http_read_contract(saved["artifact"],tool,policy) + self.assertEqual(row["supported"],original["supported"]) + if original["supported"]: self.assertEqual(row["contract_digest"],original["contract_digest"]) + else: self.assertEqual(row["error"],original["error"]) + policy["read_operations"]=["inspectRecords"] + selected=WORKER.http_read_report(saved,tools,policy,0,100) + self.assertNotEqual(selected["policy_digest"],first["policy_digest"]) + self.assertEqual(selected["summary"]["supported"],3) + self.assertEqual(selected["summary"]["excluded_parameter_count"],2) + + def test_page_bounds_empty_collection_and_large_exclusions(self): + saved=envelope(asset_spec()) + for offset,limit in ((-1,1),(True,1),(0,0),(0,501),(0,True),(1,1)): + with self.assertRaises(WORKER.WorkerError): WORKER.http_read_report(saved,[],None,offset,limit) + empty=WORKER.http_read_report(saved,[],None,0,100) + self.assertEqual(empty["tools"],[]);self.assertIsNone(empty["page"]["next_offset"]) + self.assertFalse(empty["connection_present"]) + source=asset_spec() + source["paths"]["/assets"]["get"]["parameters"]=[{"name":("filter"+str(i))*100,"in":"header","schema":{"type":"string"}} for i in range(40)] + result=WORKER.http_read_report(envelope(source),tools_for(source),None,0,100) + row=next(row for row in result["tools"] if row["tool"]["text"]=="listAssets") + self.assertEqual(row["excluded_parameter_count"],40) + self.assertEqual(len(row["excluded_parameters"]),32) + self.assertFalse(row["excluded_parameters_complete"]) + self.assertTrue(row["excluded_parameters"][0]["name"]["omitted"]) + self.assertLess(len(WORKER.canonical(result)),1024*1024) + aliases=[{"name":f"alias{i:04}","metadata":{"source_url":"fixture","path":"/assets","method":"get"}} for i in range(200)] + page=WORKER.http_read_report(envelope(source),aliases,None,0,500) + self.assertLess(len(page["tools"]),200) + self.assertEqual(page["page"]["next_offset"],len(page["tools"])) + self.assertEqual(page["summary"]["total"],200) + self.assertLess(len(WORKER.canonical(page)),1024*1024+4096) + + +@unittest.skipUnless(importlib.util.find_spec("graph_tool_call"),"pinned backend opt-in") +class WorkerReportTests(unittest.TestCase): + setUp=base.WorkerTests.setUp + call=base.WorkerTests.call + install=base.WorkerTests.install + + def test_saved_collection_report_and_snapshot_check(self): + self.install("assets",asset_spec()) + result=self.call("check_http",name="assets",offset=0,limit=1) + self.assertEqual(result["summary"]["total"],2) + self.assertEqual(len(result["tools"]),1) + self.assertEqual(result["page"]["next_offset"],1) + with self.assertRaises(WORKER.WorkerError): + self.call("check_http",name="assets",expected_digest="0"*64) + + +@unittest.skipUnless(os.environ.get("XGEN_TOOL_TEST_BINARY"),"compiled CLI opt-in") +class CliReportTests(unittest.TestCase): + # Reuse transport fixtures without inheriting and rerunning the HTTP execution suite. + setUp=http.HttpCliTests.setUp + server=http.HttpCliTests.server + cli=http.HttpCliTests.cli + install=http.HttpCliTests.install + install_post=http.HttpCliTests.install_post + + def test_held_out_reports_match_describe_without_credentials_or_network(self): + for name in ("shipments","sensors"): + arguments=self.install(name) + self.env.pop("XGEN_FIXTURE_BEARER",None) + report=json.loads(self.cli("tools","check-http","--name",name).stdout) + self.assertEqual(report["scope"],"imported_tools") + self.assertTrue(report["connection_present"]) + self.assertFalse(report["credentials_checked"]);self.assertFalse(report["network_checked"]) + self.assertEqual(report["summary"]["supported"],1) + self.assertEqual(report["tools"][0]["contract_digest"],arguments["contractDigest"]) + self.assertEqual(self.calls,[]);self.assertEqual(self.requests,[]) + self.assertNotIn(self.secret,json.dumps(report)) + + def test_held_out_optional_exclusions_are_visible(self): + for name in ("shipments","sensors"): + source=http.held_out_spec(name) + operation=next(iter(source["paths"].values()))["get"] + operation["parameters"].append({"name":"trace","in":"header","type":"string","schema":{"type":"string"}}) + self.install(name,source) + report=json.loads(self.cli("tools","check-http","--name",name).stdout) + self.assertEqual(report["summary"]["excluded_parameter_count"],1) + self.assertEqual(report["tools"][0]["excluded_parameters"][0]["name"]["text"],"trace") + self.assertEqual(report["summary"]["excluded_parameter_reasons"],{"http_parameter_location_unsupported":1}) + self.assertEqual(self.calls,[]) + + def test_selected_post_policy_is_reported(self): + for name in ("shipments","sensors"): + arguments,description=self.install_post(name) + report=json.loads(self.cli("tools","check-http","--name",name,"--limit","1").stdout) + self.assertEqual(report["summary"]["supported"],1) + self.assertEqual(report["tools"][0]["contract_digest"],description["http_read"]["contract_digest"]) + self.assertEqual(report["tools"][0]["method"]["text"],"post") + arguments,description=self.install_post("unapproved",approve=False) + report=json.loads(self.cli("tools","check-http","--name","unapproved").stdout) + self.assertEqual(report["summary"]["unsupported_reasons"],{"http_method_not_read_approved":1}) + self.assertEqual(self.calls,[]);self.assertEqual(self.requests,[])