From 636bf264de03ab1e0a07998e9d1c1223bfd8cbeb Mon Sep 17 00:00:00 2001 From: toki Date: Mon, 25 May 2026 22:39:44 +0900 Subject: [PATCH] update roadmap and implement runtime proto changes --- agent-ops/roadmap/ROADMAP.md | 4 +- .../milestones/model-serving-load-routing.md | 83 ++++++----- agent-ops/roadmap/current.md | 4 +- .../agent-bootstrap-oto-enrollment.md | 25 +--- .../milestones/remote-terminal-bridge-poc.md | 15 +- ...ecialized-agent-proto-socket-foundation.md | 22 +-- .../client-integration-clone-adoption.md | 18 +-- .../milestones/control-plane-portal.md | 18 +-- .../milestones/multi-edge-operations.md | 14 +- .../milestones/policy-history-audit.md | 14 +- .../serving-routing-optimization/PHASE.md | 6 +- .../knowledge-tool-validation-optimization.md | 15 +- apps/edge/internal/openai/server.go | 107 ++++++++++++- apps/edge/internal/openai/server_test.go | 79 +++++++++- apps/edge/internal/service/service.go | 62 +++++++- apps/node/internal/adapters/ollama/ollama.go | 141 ++++++++++++++++-- .../internal/adapters/ollama/ollama_test.go | 95 +++++++++++- apps/node/internal/node/node.go | 4 + apps/node/internal/runtime/types.go | 1 + proto/gen/iop/runtime.pb.go | 8 +- proto/iop/runtime.proto | 1 + 21 files changed, 575 insertions(+), 161 deletions(-) rename agent-ops/roadmap/{ => archive}/phase/serving-routing-optimization/milestones/model-serving-load-routing.md (57%) diff --git a/agent-ops/roadmap/ROADMAP.md b/agent-ops/roadmap/ROADMAP.md index 0b3918b..32fa461 100644 --- a/agent-ops/roadmap/ROADMAP.md +++ b/agent-ops/roadmap/ROADMAP.md @@ -49,8 +49,8 @@ RAG, context 구성/압축, web search, MCP 정책, tool policy, output validati - 요청 내용, 현재 브랜치, 변경 파일, 관련 코드 경로를 보고 가장 관련 있는 활성 Phase와 Milestone 문서를 같은 세션에서 1회 읽는다. - 활성 Phase 또는 Milestone 밖의 작업이면 이 문서의 Phase 흐름을 확인하고 사용자에게 진행 또는 전환 여부를 확인한다. - 이 문서는 로드맵 생성/갱신, Phase 전환, Phase 추가/수정, 전체 구조 변경 요청이 있을 때만 읽는다. -- 상세 작업과 완료 기준은 각 Milestone 문서의 `필수 기능`, `완료 기준`으로 관리한다. -- 모든 필수 작업과 완료 기준이 충족된 Milestone은 먼저 `[검토중]`으로 두고, 사용자 완료 확인과 archive 승인을 받은 뒤 `[완료]`로 전환한다. +- 상세 작업은 각 Milestone 문서의 `기능`으로 관리한다. 검증이 필요한 기능만 같은 Task 안에 `검증:`으로 통합한다. +- 모든 기능 Task와 Task 안에 명시된 검증이 충족된 Milestone은 먼저 `[검토중]`으로 두고, 사용자 완료 확인과 archive 승인을 받은 뒤 `[완료]`로 전환한다. - 완료된 Phase는 archive Phase 문서 경로로 이동하고, 하위 Milestone도 같은 archive Phase scaffold 아래에 둔다. - 진행중 Phase 안에서 완료된 Milestone은 활성 Phase 문서에 짧은 링크를 남기고, 상세 문서는 해당 archive Phase 하위 `milestones/` 경로로 이동한다. - archive `PHASE.md`는 Phase 자체가 완료 또는 폐기될 때만 만들며, 진행중 Phase의 완료 Milestone만 archive된 경우 archive Phase 디렉터리에 `milestones/`만 있을 수 있다. diff --git a/agent-ops/roadmap/phase/serving-routing-optimization/milestones/model-serving-load-routing.md b/agent-ops/roadmap/archive/phase/serving-routing-optimization/milestones/model-serving-load-routing.md similarity index 57% rename from agent-ops/roadmap/phase/serving-routing-optimization/milestones/model-serving-load-routing.md rename to agent-ops/roadmap/archive/phase/serving-routing-optimization/milestones/model-serving-load-routing.md index 2d7ff65..240b207 100644 --- a/agent-ops/roadmap/phase/serving-routing-optimization/milestones/model-serving-load-routing.md +++ b/agent-ops/roadmap/archive/phase/serving-routing-optimization/milestones/model-serving-load-routing.md @@ -13,7 +13,7 @@ Edge에 OpenAI API 방식으로 접속한 외부 agent/client가 Edge-Node 소 ## 상태 -[진행중] +[완료] ## 구현 잠금 @@ -35,54 +35,54 @@ Edge에 OpenAI API 방식으로 접속한 외부 agent/client가 Edge-Node 소 ### Epic: [e2e-serving-core] E2E Serving Core -- [ ] [edge-node-ollama-path] Edge OpenAI-compatible API 요청이 Edge-Node 소켓을 통해 Node Ollama adapter로 전달되는 E2E 경로가 검증되어 있다. -- [ ] [manual-node-select] Node 선택은 자동 라우팅이 아니라 `openai.node` 또는 단일 Node fallback 같은 수동 설정 기준으로 동작한다. -- [ ] [manual-model-select] model 선택은 요청 `model` 또는 `openai.target` 고정값을 통해 수동으로 검증할 수 있다. -- [ ] [routing-later] 자동 라우팅, 부하 라우팅, cloud fallback, 품질 평가 feedback은 후속 마일스톤으로 분리되어 있다. +- [x] [edge-node-ollama-path] Edge OpenAI-compatible API 요청이 Edge-Node 소켓을 통해 Node Ollama adapter로 전달되는 E2E 경로가 검증되어 있다. +- [x] [manual-node-select] Node 선택은 자동 라우팅이 아니라 `openai.node` 또는 단일 Node fallback 같은 수동 설정 기준으로 동작한다. +- [x] [manual-model-select] model 선택은 요청 `model` 또는 `openai.target` 고정값을 통해 수동으로 검증할 수 있다. +- [x] [routing-later] 자동 라우팅, 부하 라우팅, cloud fallback, 품질 평가 feedback은 후속 마일스톤으로 분리되어 있다. ### Epic: [ollama-openai-cycle] Ollama OpenAI-Compatible Full Cycle -- [ ] [models-list] Edge `/v1/models`가 Node/Ollama에서 사용 가능한 model 목록을 외부 client가 기대하는 OpenAI-compatible 형식으로 제공한다. -- [ ] [chat-cycle] Edge `/v1/chat/completions` 호출이 내부 `adapter=ollama`, `target=` 실행으로 변환되어 Node Ollama adapter까지 왕복한다. -- [ ] [streaming-cycle] non-streaming 응답과 streaming SSE 응답이 일반 OpenAI-compatible client에서 사용할 수 있는 형태로 동작한다. -- [ ] [model-target-map] `openai.target` 고정 라우팅과 요청 `model` 기반 라우팅의 우선순위가 명확하며 Ollama model 선택 경험과 충돌하지 않는다. -- [ ] [option-pass-through] temperature, top_p, max_tokens, stop, stream 등 1차 지원 option의 매핑과 미지원 option 응답 정책이 정리되어 있다. -- [ ] [thinking-passthrough] Ollama `message.thinking` stream이 Edge-Node `RunEvent`에서 content delta와 구분되어 전달된다. -- [ ] [openai-reasoning-ux] Edge OpenAI-compatible streaming/non-streaming 응답이 thinking을 `reasoning_content` 계열 확장 필드로 노출하고, 답변 content stream과 섞지 않는다. -- [ ] [think-control] `think:false`는 기본값이 아니라 명시 옵션 또는 후속 client별 제어로만 다루며, 기본 UX는 thinking 보존으로 둔다. -- [ ] [ollama-command-pass-through] Ollama 관련 모델 조회, 상태, 관리 command를 Edge OpenAI-compatible 표면에서 어디까지 pass-through할지 1차 범위가 정의되어 있다. -- [ ] [openai-ollama-aux-smoke] fake Ollama 기반 보조 smoke가 OpenAI-compatible 입력 표면과 edge-node relay의 최소 생존을 확인한다. -- [ ] [toki-labs-field-flow] 공통 필드 테스트 환경에서 `toki-labs` Ollama `gemma4:26b` 모델을 사용해 사용자가 직접 재현 가능한 bin edge-node 흐름이 문서화되고 검증된다. +- [x] [models-list] Edge `/v1/models`가 Node/Ollama에서 사용 가능한 model 목록을 외부 client가 기대하는 OpenAI-compatible 형식으로 제공한다. +- [x] [chat-cycle] Edge `/v1/chat/completions` 호출이 내부 `adapter=ollama`, `target=` 실행으로 변환되어 Node Ollama adapter까지 왕복한다. +- [x] [streaming-cycle] non-streaming 응답과 streaming SSE 응답이 일반 OpenAI-compatible client에서 사용할 수 있는 형태로 동작한다. +- [x] [model-target-map] `openai.target` 고정 라우팅과 요청 `model` 기반 라우팅의 우선순위가 명확하며 Ollama model 선택 경험과 충돌하지 않는다. +- [x] [option-pass-through] temperature, top_p, max_tokens, stop, stream 등 1차 지원 option의 매핑과 미지원 option 응답 정책이 정리되어 있다. +- [x] [thinking-passthrough] Ollama `message.thinking` stream이 Edge-Node `RunEvent`에서 content delta와 구분되어 전달된다. +- [x] [openai-reasoning-ux] Edge OpenAI-compatible streaming/non-streaming 응답이 thinking을 `reasoning_content` 계열 확장 필드로 노출하고, 답변 content stream과 섞지 않는다. +- [x] [think-control] `think:false`는 기본값이 아니라 명시 옵션 또는 후속 client별 제어로만 다루며, 기본 UX는 thinking 보존으로 둔다. +- [x] [ollama-command-pass-through] Ollama 관련 모델 조회, 상태, 관리 command를 Edge OpenAI-compatible 표면에서 어디까지 pass-through할지 1차 범위가 정의되어 있다. +- [x] [openai-ollama-aux-smoke] fake Ollama 기반 보조 smoke가 OpenAI-compatible 입력 표면과 edge-node relay의 최소 생존을 확인한다. +- [x] [toki-labs-field-flow] 공통 필드 테스트 환경에서 `toki-labs` Ollama `gemma4:26b` 모델을 사용해 사용자가 직접 재현 가능한 bin edge-node 흐름이 문서화되고 검증된다. ### Epic: [protocol-boundary] Protocol Boundary -- [ ] [a2a-boundary] A2A API는 외부 agent의 작업 위임 표면으로 제한한다. -- [ ] [nomadcode-a2a] NomadCode의 A2A 도입 시점은 이 Milestone에서 강제하지 않고 별도 판단으로 남긴다. -- [ ] [native-protocol] IOP native protocol은 proto-socket 기반 내부/운영 호출 기준으로 두고 OpenAI-compatible UX에 운영 제어 기능을 억지로 싣지 않는다. -- [ ] [optimization-later] RAG, MCP, web search, output validation은 기본 serving/load routing 이후 단계로 명시한다. +- [x] [a2a-boundary] A2A API는 외부 agent의 작업 위임 표면으로 제한한다. +- [x] [nomadcode-a2a] NomadCode의 A2A 도입 시점은 이 Milestone에서 강제하지 않고 별도 판단으로 남긴다. +- [x] [native-protocol] IOP native protocol은 proto-socket 기반 내부/운영 호출 기준으로 두고 OpenAI-compatible UX에 운영 제어 기능을 억지로 싣지 않는다. +- [x] [optimization-later] RAG, MCP, web search, output validation은 기본 serving/load routing 이후 단계로 명시한다. ## 완료 기준 -- [ ] Edge `/v1/models`와 `/v1/chat/completions`를 통해 Node의 Ollama serving을 조회하고 사용할 수 있다. -- [ ] 외부 agent/client가 Edge endpoint를 일반 OpenAI-compatible 또는 Ollama-backed endpoint처럼 설정해 한 차례 모델 조회와 chat completion을 완료할 수 있다. -- [ ] streaming/non-streaming 응답, thinking/reasoning passthrough, model-to-target 매핑, 1차 option pass-through, unsupported option 응답 정책이 검증되어 있다. -- [ ] `gemma4:26b`처럼 thinking을 먼저 내보내는 모델에서도 사용자는 thinking 중임을 볼 수 있고, content가 시작되면 답변 stream을 자연스럽게 받을 수 있다. -- [ ] `bin/edge.sh`와 `bin/node.sh`를 실제로 연결해 Edge -> Node -> Ollama 흐름을 재현할 수 있다. -- [ ] 최종 완료 검증은 fake Ollama smoke가 아니라 사용자가 직접 테스트하는 방식과 같은 `toki-labs` 실제 Ollama 필드 흐름으로 수행된다. -- [ ] 공통 필드 테스트 환경의 Node host, Ollama endpoint, model, edge/node 임시 config 기준이 문서화되어 있다. -- [ ] Node/model 선택은 수동 설정으로 검증하고, 자동 라우팅과 부하 라우팅은 후속 작업으로 분리되어 있다. -- [ ] 외부 API 표면과 IOP native protocol의 역할이 충돌하지 않는다. -- [ ] 다음 단계인 model routing/fallback 최적화로 넘어갈 선행 조건이 정의된다. +- [x] Edge `/v1/models`와 `/v1/chat/completions`를 통해 Node의 Ollama serving을 조회하고 사용할 수 있다. +- [x] 외부 agent/client가 Edge endpoint를 일반 OpenAI-compatible 또는 Ollama-backed endpoint처럼 설정해 한 차례 모델 조회와 chat completion을 완료할 수 있다. +- [x] streaming/non-streaming 응답, thinking/reasoning passthrough, model-to-target 매핑, 1차 option pass-through, unsupported option 응답 정책이 검증되어 있다. +- [x] `gemma4:26b`처럼 thinking을 먼저 내보내는 모델에서도 사용자는 thinking 중임을 볼 수 있고, content가 시작되면 답변 stream을 자연스럽게 받을 수 있다. +- [x] `bin/edge.sh`와 `bin/node.sh`를 실제로 연결해 Edge -> Node -> Ollama 흐름을 재현할 수 있다. +- [x] 최종 완료 검증은 fake Ollama smoke가 아니라 사용자가 직접 테스트하는 방식과 같은 `toki-labs` 실제 Ollama 필드 흐름으로 수행된다. +- [x] 공통 필드 테스트 환경의 Node host, Ollama endpoint, model, edge/node 임시 config 기준이 문서화되어 있다. +- [x] Node/model 선택은 수동 설정으로 검증하고, 자동 라우팅과 부하 라우팅은 후속 작업으로 분리되어 있다. +- [x] 외부 API 표면과 IOP native protocol의 역할이 충돌하지 않는다. +- [x] 다음 단계인 model routing/fallback 최적화로 넘어갈 선행 조건이 정의된다. ## 완료 리뷰 -- 상태: 없음 -- 요청일: 없음 -- 완료 근거: 모든 필수 Task와 완료 기준이 아직 모두 충족되지 않았다. +- 상태: 승인됨 +- 요청일: 2026-05-25 +- 완료 근거: 실제 `toki-labs` Ollama `gemma4:26b` 필드 흐름에서 `/v1/models`, non-streaming chat, streaming SSE, reasoning/content 분리, `data: [DONE]`, Ollama native `/api/tags`와 `/api/show` passthrough, Ollama option passthrough가 검증되었다. 다음 routing/fallback 선행 조건도 하드코딩 route test, 모든 에러 fallback raising, Ollama 기준 model profile, 수동 node/target 독립 제약으로 정리했다. - 리뷰 필요: - - [ ] 사용자가 완료 결과를 확인했다 - - [ ] archive 이동을 승인했다 -- 리뷰 코멘트: 없음 + - [x] 사용자가 완료 결과를 확인했다 + - [x] archive 이동을 승인했다 +- 리뷰 코멘트: 2026-05-25 사용자 확인에 따라 완료 처리하고 archive 이동한다. ## 범위 제외 @@ -104,6 +104,17 @@ Edge에 OpenAI API 방식으로 접속한 외부 agent/client가 Edge-Node 소 - 표준선(선택): Ollama thinking은 `RunEvent.type=reasoning_delta` 같은 별도 event type으로 content delta와 구분하고, OpenAI-compatible SSE에서는 `delta.reasoning_content` 확장 필드로 노출한다. - 표준선(선택): non-streaming 응답은 최종 `message.content`에 답변만 담고, thinking은 `message.reasoning_content` 확장 필드로 분리한다. - 표준선(선택): `think:false`는 테스트 우회 기본값으로 쓰지 않고, 명시 요청 또는 후속 설정 항목으로만 고려한다. +- 진행 근거(2026-05-25): `b7d636c`에서 `EventTypeReasoningDelta`, Ollama `message.thinking` 분리 event, OpenAI-compatible `reasoning_content` streaming/non-streaming 응답, fake Ollama smoke의 reasoning 검증이 반영되어 있다. +- 검증 근거(2026-05-25): `go test ./apps/edge/internal/openai ./apps/node/internal/adapters/ollama`, `scripts/e2e-openai-ollama.sh` 통과. +- 검증 근거(2026-05-25): 임시 Edge/Node config로 `bin/edge.sh`와 `bin/node.sh`를 실제 실행하고 `http://192.168.0.97:11434`의 `gemma4:26b`를 호출했다. `/v1/models`는 `gemma4:26b`를 반환했고, non-streaming 응답 content는 `IOP_OLLAMA_E2E_OK`, reasoning 길이는 681 bytes였다. streaming SSE는 content delta를 이어 붙였을 때 `IOP_OLLAMA_STREAM_OK`, reasoning 길이는 695 bytes였고 `data: [DONE]`을 확인했다. +- 검증 메모(2026-05-25): `ssh toki@toki-labs.com` 원격 host는 접근 가능하지만 확인한 기본 경로에는 repo가 없고 `go`도 PATH에 없어, 이번 검증은 로컬 임시 Node가 실제 Ollama endpoint를 호출하는 방식으로 수행했다. +- 정책 확정(2026-05-25): OpenAI generation option과 Ollama 관련 command pass-through는 IOP가 별도 의미를 재정의하지 않고 Ollama가 지원하는 형식을 그대로 전달/노출하는 것을 기준으로 한다. +- 선행 조건 확정(2026-05-25): routing/fallback 최적화의 route test는 초기에는 하드코딩한다. 모든 에러는 fallback raising 조건이며, 에러 분류는 관측/정책 판단을 위한 구분일 뿐 fallback 트리거 자체를 막지 않는다. +- 선행 조건 확정(2026-05-25): 품질 평가, cloud fallback, 자동 부하 분산은 이 Milestone의 후속 단계에서 제외한다. +- 선행 조건 확정(2026-05-25): model profile은 IOP 고유 profile taxonomy를 새로 만들지 않고 Ollama가 제공/지원하는 model 정보와 option/capability 형식을 기준으로 맞춘다. +- 선행 조건 확정(2026-05-25): 수동 `node`와 `target`은 우선순위 경쟁 관계가 아니라 독립 제약이다. 명시된 node에 target이 없거나 실행할 수 없으면 자동 보정하지 않고 에러로 처리한다. +- 검증 근거(2026-05-25): 실제 `toki-labs` Ollama field 환경에서 option 포함 non-streaming 응답 content는 `IOP_OLLAMA_OPTIONS_OK`, streaming SSE는 content delta를 이어 붙였을 때 `IOP_OLLAMA_STREAM_OK`, reasoning 길이는 537 bytes였고 `data: [DONE]`을 확인했다. Ollama native `/api/tags`는 6개 model을 반환했고 `/api/show`는 `gemma4:26b` model info를 반환했다. +- 완료 후보(2026-05-25): 모든 필수 기능과 완료 기준이 evidence와 함께 충족되어 `[검토중]`으로 전환한다. - 선행 작업: Edge 입력 표면, CLI Automation Runtime 안정화 - 후속 작업: 모델 라우팅/fallback 최적화, 지식/도구 정책/검증 최적화 - 확인 필요: cloud fallback 도입 시점, Responses API 세부 호환 범위, 모델 품질 평가와 routing feedback의 초기 신호 diff --git a/agent-ops/roadmap/current.md b/agent-ops/roadmap/current.md index 7fb07aa..16c5368 100644 --- a/agent-ops/roadmap/current.md +++ b/agent-ops/roadmap/current.md @@ -13,9 +13,9 @@ ## 활성 Milestone -- [진행중] Ollama E2E 서빙 안정화 +- [계획] 지식, 도구 정책, 검증 최적화 - Phase: `agent-ops/roadmap/phase/serving-routing-optimization/PHASE.md` - - 경로: `agent-ops/roadmap/phase/serving-routing-optimization/milestones/model-serving-load-routing.md` + - 경로: `agent-ops/roadmap/phase/serving-routing-optimization/milestones/knowledge-tool-validation-optimization.md` - [검토중] Specialized Agent proto-socket 연결 기반 - Phase: `agent-ops/roadmap/phase/automation-runtime-bridge/PHASE.md` diff --git a/agent-ops/roadmap/phase/automation-runtime-bridge/milestones/agent-bootstrap-oto-enrollment.md b/agent-ops/roadmap/phase/automation-runtime-bridge/milestones/agent-bootstrap-oto-enrollment.md index 63873b8..b01fdf6 100644 --- a/agent-ops/roadmap/phase/automation-runtime-bridge/milestones/agent-bootstrap-oto-enrollment.md +++ b/agent-ops/roadmap/phase/automation-runtime-bridge/milestones/agent-bootstrap-oto-enrollment.md @@ -31,7 +31,7 @@ OTO는 build/deploy domain agent의 대표 후보로 둔다. - OTO가 확정한 Linux MVP bootstrap command, release asset, config path, background 실행 기준을 Edge bootstrap 발급 계약으로 수용 - Edge가 OTO agent 생성, command 발급, registration accepted, 최초 heartbeat 기반 online 상태를 구분해 표현하는 기준 정리 -## 필수 기능 +## 기능 ### Epic: [agent-boundary] Agent Boundary and Enrollment Path @@ -41,32 +41,23 @@ OTO는 build/deploy domain agent의 대표 후보로 둔다. ### Epic: [bootstrap-command] Bootstrap Command Contract -- [ ] [bootstrap-command] Edge가 OTO agent 생성 후 Linux 대상에서 바로 실행할 one-line bootstrap command를 발급하는 계약이 정의되어 있다. +- [ ] [bootstrap-command] Edge가 OTO agent 생성과 bootstrap command 발급 상태를 표현하고 Linux 대상에서 바로 실행할 one-line bootstrap command를 발급하는 계약이 정의되어 있다. - [ ] [agent-record] Edge-side agent record가 `agent_kind=oto-agent`, agent id, alias, enrollment token, bootstrap 발급 상태를 소유하는 기준이 정의되어 있다. -- [ ] [oto-contract] OTO의 bootstrap script 인자, release asset 이름, config path, background 실행 기준이 iop Edge 발급 계약에 반영되어 있다. -- [ ] [registration-state] Edge 등록 accepted와 최초 heartbeat 도착을 online 판정으로 분리하고, bootstrap issued/registered/online/failure 상태 기준이 정의되어 있다. +- [ ] [oto-contract] OTO의 bootstrap script 인자, release asset 이름, config path, background 실행 기준이 iop Edge 발급 계약에 반영되어 있고 OTO 로드맵 계약과 충돌하지 않는다. +- [ ] [registration-state] bootstrap command 실행 후 agent config/service 등록과 Edge outbound enrollment 흐름이 문서화되고, `bootstrap issued`, `registration accepted`, `first heartbeat received`, `online`, `failed` 상태 기준이 구분되어 있다. - [ ] [insecure-policy] HTTP local/insecure bootstrap 모드는 명시 선택과 추가 검증 조건을 요구한다. ### Epic: [ownership-boundary] IOP and OTO Ownership -- [ ] [ownership] iop와 OTO의 책임 경계가 문서화되어 있다. - - [ ] [iop-ownership] iop는 Edge agent registry, bootstrap command 발급, credential, 보안 검증 기준을 소유한다. - - [ ] [oto-ownership] OTO는 build/deploy domain agent의 설치 산출물, 설정 생성, YAML run/capability/event 의미를 소유한다. - -## 완료 기준 - -- [ ] Edge가 agent 생성과 bootstrap command 발급 상태를 표현할 수 있다. -- [ ] bootstrap command 실행 후 agent config/service 등록과 Edge outbound enrollment 흐름이 문서화된다. -- [ ] OTO agent와 generic Node의 책임 경계가 registry와 protocol 수준에서 구분된다. -- [ ] Edge가 발급하는 OTO bootstrap command의 필수/선택 인자와 OTO release asset 선택 규칙이 OTO 로드맵 계약과 충돌하지 않는다. -- [ ] Edge-side 상태 모델에서 `bootstrap issued`, `registration accepted`, `first heartbeat received`, `online`, `failed`가 구분된다. -- [ ] OTO의 Edge bootstrap 계약 문서가 이 Milestone의 산출물을 참조해 재개 가능한 상태가 된다. +- [ ] [ownership] OTO agent와 generic Node의 책임 경계가 registry와 protocol 수준에서 구분되고 iop와 OTO의 책임 경계가 문서화되어 있다. +- [ ] [iop-ownership] iop는 Edge agent registry, bootstrap command 발급, credential, 보안 검증 기준을 소유한다. +- [ ] [oto-ownership] OTO는 build/deploy domain agent의 설치 산출물, 설정 생성, YAML run/capability/event 의미를 소유하고, OTO의 Edge bootstrap 계약 문서는 이 Milestone 산출물을 참조해 재개 가능하다. ## 완료 리뷰 - 상태: 없음 - 요청일: 없음 -- 완료 근거: 모든 필수 Task와 완료 기준이 아직 모두 충족되지 않았다. +- 완료 근거: 모든 기능 Task가 아직 충족되지 않았다. - 리뷰 필요: - [ ] 사용자가 완료 결과를 확인했다 - [ ] archive 이동을 승인했다 diff --git a/agent-ops/roadmap/phase/automation-runtime-bridge/milestones/remote-terminal-bridge-poc.md b/agent-ops/roadmap/phase/automation-runtime-bridge/milestones/remote-terminal-bridge-poc.md index 5dd8d53..d3d7d0f 100644 --- a/agent-ops/roadmap/phase/automation-runtime-bridge/milestones/remote-terminal-bridge-poc.md +++ b/agent-ops/roadmap/phase/automation-runtime-bridge/milestones/remote-terminal-bridge-poc.md @@ -30,27 +30,22 @@ Edge는 terminal session broker가 되고, 대상에 도달 가능한 Node가 SS - 대상 접근 경로와 credential이 있는 경우만 제어할 수 있다는 전제 명시 - target allowlist, timeout, 동시 실행 제한, session audit 요구사항 초안 -## 필수 기능 +## 기능 ### Epic: [bridge-boundary] Remote Terminal Bridge Boundary - [ ] [agentless-boundary] 원격 터미널 브리지는 완전 agentless 제어가 아니라 target device에 IOP agent를 설치하지 않는 운영 경로로 정의한다. - [ ] [edge-broker] Control Plane/Portal/운영 CLI는 Edge에 terminal session을 요청하고, Node에 직접 붙지 않는다. - [ ] [node-transport] Node terminal transport는 adapter/profile 또는 별도 adapter 경계에서 실행된다. -- [ ] [terminal-events] stdin/stdout/stderr, resize, signal, close lifecycle을 IOP native protocol에서 표현하는 방향을 검증한다. - -## 완료 기준 - -- [ ] 허용된 target profile을 통해 Edge에서 remote shell session을 열고 입출력을 relay할 수 있다. -- [ ] session lifecycle과 adapter execution stream의 경계가 문서화된다. -- [ ] 보안 전제(target allowlist, credential, timeout, audit)가 milestone 산출물에 포함된다. -- [ ] OpenAI-compatible API나 A2A task payload에 terminal 제어 기능을 억지로 싣지 않는다. +- [ ] [terminal-events] stdin/stdout/stderr, resize, signal, close lifecycle과 adapter execution stream 경계를 IOP native protocol에서 표현한다. 검증: 허용된 target profile로 Edge remote shell session을 열고 입출력 relay가 되는지 확인한다. +- [ ] [security-boundary] target allowlist, credential, timeout, audit 보안 전제를 milestone 산출물에 포함한다. +- [ ] [surface-boundary] OpenAI-compatible API나 A2A task payload에 terminal 제어 기능을 억지로 싣지 않는다. ## 완료 리뷰 - 상태: 없음 - 요청일: 없음 -- 완료 근거: 모든 필수 Task와 완료 기준이 아직 모두 충족되지 않았다. +- 완료 근거: 모든 기능 Task와 Task 안에 명시된 검증이 아직 충족되지 않았다. - 리뷰 필요: - [ ] 사용자가 완료 결과를 확인했다 - [ ] archive 이동을 승인했다 diff --git a/agent-ops/roadmap/phase/automation-runtime-bridge/milestones/specialized-agent-proto-socket-foundation.md b/agent-ops/roadmap/phase/automation-runtime-bridge/milestones/specialized-agent-proto-socket-foundation.md index e23c738..7ff3d5a 100644 --- a/agent-ops/roadmap/phase/automation-runtime-bridge/milestones/specialized-agent-proto-socket-foundation.md +++ b/agent-ops/roadmap/phase/automation-runtime-bridge/milestones/specialized-agent-proto-socket-foundation.md @@ -28,39 +28,31 @@ OTO 쪽 Dart `proto_socket` 연결과 iop Edge 등록 smoke가 이미 시작된 - `RunRequest/RunEvent`, `CancelRequest`, `NodeCommandRequest/Response`의 envelope 재사용 범위와 OTO 전용 build/deploy payload, artifact/log/progress 의미의 분리 기준을 잡는다. - OTO Dart `proto_socket` consumer와 iop Go Edge transport 사이의 smoke 범위를 registration에서 run/command까지 확장할지 판단한다. -## 필수 기능 +## 기능 ### Epic: [node-compatible-flow] Node-Compatible Flow Mapping -- [x] [node-flow-map] 기존 Edge-Node 등록, config, run/event, command, heartbeat 흐름이 specialized agent 관점에서 매핑되어 있다. -- [x] [oto-interop] OTO Dart `proto_socket` consumer가 iop Edge의 기존 `RegisterRequest/RegisterResponse` 등록 smoke 경로를 통과하는 근거가 확인되어 있다. +- [x] [node-flow-map] 기존 Edge-Node 등록, config, run/event, command, heartbeat 흐름이 specialized agent 관점에서 매핑되어 있고 Agent Bootstrap과 OTO 등록이 통신 기반을 추측하지 않고 후속 계약을 작성할 수 있다. +- [x] [oto-interop] OTO Dart `proto_socket` consumer가 iop Edge의 기존 `RegisterRequest/RegisterResponse` 등록 smoke 경로를 통과하는 근거가 확인되어 있고 OTO의 proto-socket 통신 기반 Milestone과 같은 책임 경계를 참조한다. 검증: OTO Dart client의 iop Edge registration smoke 근거와 후속 run/command smoke 요구 범위가 문서에 남아 있다. +- [x] [runtime-path-boundary] generic Node, specialized domain agent, remote terminal bridge 경로가 제품/transport 수준에서 구분된다. - [x] [bootstrap-precondition] Agent Bootstrap과 OTO 등록이 의존할 transport/handshake 선행 조건이 명확하다. ### Epic: [agent-kind-registry] Agent Kind와 Registry Entry -- [x] [agent-kind] Edge registry에서 generic Node와 specialized domain agent를 구분하는 기준 후보가 정리되어 있다. +- [x] [agent-kind] Edge registry에서 generic Node와 specialized domain agent를 구분하는 기준 후보와 proto field 추가가 필요한 시점/조건이 정리되어 있다. - [x] [registry-entry] connected entry가 agent kind, capability, lifecycle을 가질 수 있는지, 기존 `NodeEntry`/registry를 확장할지 별도 registry로 분리할지 기준이 정리되어 있다. - [x] [reconnect-policy] duplicate connection, reconnect, 기존 연결 대체/거부 정책이 specialized agent 관점에서 정리되어 있다. ### Epic: [config-message-boundary] Config와 Message Boundary - [x] [config-boundary] `RegisterResponse`의 `NodeConfigPayload`를 OTO에 어떻게 적용할지, 최소 config와 OTO 전용 config 분리 기준이 정리되어 있다. -- [x] [message-boundary] 기존 Node 메시지 재사용과 specialized agent 전용 메시지 분리 기준이 문서화되어 있다. - -## 완료 기준 - -- [x] Agent Bootstrap과 OTO 등록이 통신 기반을 추측하지 않고 후속 계약을 작성할 수 있다. -- [x] OTO의 proto-socket 통신 기반 Milestone과 같은 책임 경계를 참조한다. -- [x] generic Node, specialized domain agent, remote terminal bridge 경로가 제품/transport 수준에서 구분된다. -- [x] OTO Dart client의 iop Edge registration smoke를 기준으로, 후속 run/command smoke를 어디까지 요구할지 결정되어 있다. -- [x] specialized agent를 처음에는 node-compatible handshake로 받아들이되, proto field 추가가 필요한 시점과 조건이 정리되어 있다. -- [x] proto schema나 registry schema를 구현하기 전에 결정해야 할 후보와 확인 필요 항목이 정리되어 있다. +- [x] [message-boundary] 기존 Node 메시지 재사용과 specialized agent 전용 메시지 분리 기준이 문서화되어 있고 proto schema나 registry schema 구현 전 결정 후보와 확인 필요 항목이 정리되어 있다. ## 완료 리뷰 - 상태: 요청됨 - 요청일: 2026-05-25 -- 완료 근거: 필수 Epic/Task와 완료 기준이 모두 충족되어 완료 후보로 정리했다. 기존 Edge-Node proto-socket 재사용, Edge-side agent kind 판별, config/message boundary, reconnect 정책, bootstrap 선행 조건이 계약 결정에 반영되어 있다. OTO 직접 연결 Phase와 remote terminal bridge Phase 경계를 참조해 generic Node, specialized agent, agentless terminal bridge 경로를 분리했다. +- 완료 근거: 모든 기능 Task와 Task 안에 명시된 검증이 충족되어 완료 후보로 정리했다. 기존 Edge-Node proto-socket 재사용, Edge-side agent kind 판별, config/message boundary, reconnect 정책, bootstrap 선행 조건이 계약 결정에 반영되어 있다. OTO 직접 연결 Phase와 remote terminal bridge Phase 경계를 참조해 generic Node, specialized agent, agentless terminal bridge 경로를 분리했다. - 리뷰 필요: - [ ] 사용자가 완료 결과를 확인했다 - [ ] archive 이동을 승인했다 diff --git a/agent-ops/roadmap/phase/control-plane-portal-ops/milestones/client-integration-clone-adoption.md b/agent-ops/roadmap/phase/control-plane-portal-ops/milestones/client-integration-clone-adoption.md index 30b648d..2c0ab4b 100644 --- a/agent-ops/roadmap/phase/control-plane-portal-ops/milestones/client-integration-clone-adoption.md +++ b/agent-ops/roadmap/phase/control-plane-portal-ops/milestones/client-integration-clone-adoption.md @@ -31,13 +31,13 @@ NomadCode의 `Client Integration Standardization` 결과를 IOP Flutter client - iOS는 동일 Dart API 위의 후속 native 확장 대상으로 문서화 - NomadCode에서 복제할 skeleton과 IOP에서 치환할 product-specific 항목 문서화 -## 필수 기능 +## 기능 ### Epic: [client-clone] Client standard clone NomadCode client 표준을 IOP client에 맞게 복제한다. -- [ ] [module-name] Flutter host 경로와 모듈명을 `client` 기준으로 정리한다. +- [ ] [module-name] IOP Flutter host 경로와 모듈명은 `client` 기준 모듈/문서 표준을 따른다. - [ ] [bootstrap] Web + Android 우선 bootstrap과 platform-specific initialization guard를 반영한다. - [ ] [folder-layout] `src/app`, `src/integrations`, `src/features` 기준의 client skeleton을 정리한다. @@ -45,9 +45,9 @@ NomadCode client 표준을 IOP client에 맞게 복제한다. 공통 integration 경계를 IOP 운영면에 맞춰 연결한다. -- [ ] [proto-socket] proto-socket connection lifecycle과 endpoint configuration을 client integration으로 정리한다. +- [ ] [proto-socket] proto-socket connection lifecycle과 endpoint configuration 책임 경계가 host/plugin/control-plane 사이에서 충돌하지 않도록 client integration으로 정리한다. - [ ] [mattermost-push] Mattermost push는 plugin runtime을 사용하고 host는 Firebase config, auth/token/signing key, routing callback만 소유한다. -- [ ] [platform-targets] Web과 Android를 1차 target으로 검증하고 iOS 후속 확장 경계를 문서화한다. +- [ ] [platform-targets] Web과 Android를 1차 target으로 두고 iOS는 동일 Dart API 위의 후속 확장 대상으로 문서화한다. 검증: Web build와 Android integration이 같은 Dart bootstrap 기준에서 설명되고 확인되는지 점검한다. ### Epic: [handoff] Clone handoff @@ -55,15 +55,7 @@ NomadCode 표준과 IOP 차이를 추적 가능하게 남긴다. - [ ] [clone-checklist] NomadCode에서 가져올 항목과 IOP에서 치환할 항목을 checklist로 남긴다. - [ ] [naming] IOP 제품 표면에서는 Portal 명칭을 강조하지 않고 client/product surface 기준으로 정리한다. -- [ ] [related-locks] Flutter client 진행 milestone은 이 milestone의 표준 적용 범위가 확정되기 전까지 잠금 상태로 둔다. - -## 완료 기준 - -- [ ] IOP Flutter host가 `client` 기준 모듈/문서 표준을 따른다. -- [ ] Web build와 Android integration이 같은 Dart bootstrap 기준에서 설명되고 검증된다. -- [ ] proto-socket과 Mattermost push integration 책임 경계가 host/plugin/control-plane 사이에서 충돌 없이 설명된다. -- [ ] iOS가 Android-only 예외가 아니라 동일 Dart API 위의 후속 확장 대상으로 문서화된다. -- [ ] 기존 Control Plane client milestone과 충돌하는 범위가 잠금 또는 후속 작업으로 정리되어 있다. +- [ ] [related-locks] 기존 Control Plane client milestone과 충돌하는 범위는 이 milestone의 표준 적용 범위가 확정되기 전까지 잠금 또는 후속 작업으로 정리한다. ## 완료 리뷰 diff --git a/agent-ops/roadmap/phase/control-plane-portal-ops/milestones/control-plane-portal.md b/agent-ops/roadmap/phase/control-plane-portal-ops/milestones/control-plane-portal.md index 8e5cd9d..5e9d3b5 100644 --- a/agent-ops/roadmap/phase/control-plane-portal-ops/milestones/control-plane-portal.md +++ b/agent-ops/roadmap/phase/control-plane-portal-ops/milestones/control-plane-portal.md @@ -33,33 +33,29 @@ Client는 NomadCode 전용 화면이 아니라 IOP가 독립적으로 운영되 - remote terminal bridge session 생성/조회/권한 확인 표면 - IOP 책임 범위 안의 model serving, automation, session, routing, policy, history, audit 운영 UX -## 필수 기능 +## 기능 ### Epic: [edge-ops-boundary] Edge Operations Boundary - [ ] [edge-mediated] Control Plane은 Node를 직접 연결하거나 직접 스케줄링하지 않는다. +- [ ] [edge-status] Control Plane은 하나 이상의 Edge 연결 상태를 조회할 수 있다. 검증: Edge 연결 상태 조회 경로가 동작하는지 확인한다. +- [ ] [edge-command-events] Edge 명령 전달과 이벤트 수신 흐름이 IOP Wire Protocol 기준으로 문서화된다. - [ ] [portal-via-cp] Client는 Control Plane을 통해 Edge 운영 상태를 조회한다. ### Epic: [portal-wire-surface] Client Wire Surface - [x] [portal-boundary] Flutter client는 IOP 자체 운영 UI/UX이며 NomadCode에 맞춘 전용 shell로 만들지 않는다. +- [ ] [client-status] Client는 Edge/Node/agent 상태를 사용자에게 보여줄 수 있다. 검증: Client 상태 화면 또는 상태 표시 경로를 확인한다. - [x] [server-bridge] 브라우저 직접 TCP가 어려운 구간은 Control Plane server-side bridge를 둔다. -- [x] [portal-wire-hello] Client-Control Plane proto-socket WebSocket hello handshake scaffold가 구현되고 테스트되어 있다. +- [x] [portal-wire-hello] Client-Control Plane proto-socket WebSocket hello handshake scaffold가 구현되고 테스트되어 있다. 검증: Control Plane과 client 테스트에서 hello handshake가 통과한다. - [x] [http-boundary] `net/http`는 health/readiness/bootstrap 같은 보조 endpoint 용도로 유지한다. - -## 완료 기준 - -- [ ] Control Plane이 하나 이상의 Edge 연결 상태를 조회할 수 있다. -- [ ] Client가 Edge/Node/agent 상태를 사용자에게 보여줄 수 있다. -- [ ] Edge 명령 전달과 이벤트 수신 흐름이 IOP Wire Protocol 기준으로 문서화된다. -- [x] Control Plane과 Flutter client의 배포/검증 절차가 `docs/deploy-dev.md`와 일치한다. -- [x] Client-Control Plane proto-socket WebSocket hello handshake가 Control Plane과 client 테스트에서 검증된다. +- [x] [deploy-docs] Control Plane과 Flutter client의 배포/검증 절차가 `docs/deploy-dev.md`와 일치한다. ## 완료 리뷰 - 상태: 없음 - 요청일: 없음 -- 완료 근거: 모든 필수 Task와 완료 기준이 아직 모두 충족되지 않았다. +- 완료 근거: 모든 기능 Task와 Task 안에 명시된 검증이 아직 충족되지 않았다. - 리뷰 필요: - [ ] 사용자가 완료 결과를 확인했다 - [ ] archive 이동을 승인했다 diff --git a/agent-ops/roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md b/agent-ops/roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md index 498fbf9..2ca7612 100644 --- a/agent-ops/roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md +++ b/agent-ops/roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md @@ -31,26 +31,22 @@ Multi-edge scheduling과 fleet-wide state ownership은 Control Plane과 IOP nati - OTO 같은 domain agent의 build/deploy 상태, artifact, capability 포함 - fleet-wide 명령과 운영 리포트 후보 -## 필수 기능 +## 기능 ### Epic: [fleet-ops-boundary] Fleet Operations Boundary - [ ] [native-fleet] Multi-edge 운영 명령과 이벤트 집계는 IOP native protocol을 기준으로 설계한다. +- [ ] [edge-status-view] Control Plane은 여러 Edge 상태를 구분해 조회하고 표시할 수 있다. 검증: 두 개 이상의 Edge 상태가 구분되는 조회/표시 경로를 확인한다. +- [ ] [history-agent-state] Edge별 실행 이력과 domain agent 상태가 운영 화면에서 구분된다. - [ ] [openai-routing] OpenAI-compatible 표면은 특정 Edge/adapter로 라우팅되는 inference 호환 경로로 제한한다. - [ ] [a2a-routing] A2A 표면은 특정 Edge/adapter로 위임되는 agent task 경로로 제한한다. -- [ ] [edge-ownership] Edge는 자신의 로컬 런타임 상태와 registry 소유권을 유지한다. - -## 완료 기준 - -- [ ] Control Plane이 여러 Edge 상태를 구분해 조회하고 표시할 수 있다. -- [ ] Edge별 실행 이력과 domain agent 상태가 운영 화면에서 구분된다. -- [ ] fleet-wide 기능이 Edge의 로컬 상태 소유권을 침범하지 않는다. +- [ ] [edge-ownership] Edge는 자신의 로컬 런타임 상태와 registry 소유권을 유지하고 fleet-wide 기능은 이를 침범하지 않는다. ## 완료 리뷰 - 상태: 없음 - 요청일: 없음 -- 완료 근거: 모든 필수 Task와 완료 기준이 아직 모두 충족되지 않았다. +- 완료 근거: 모든 기능 Task와 Task 안에 명시된 검증이 아직 충족되지 않았다. - 리뷰 필요: - [ ] 사용자가 완료 결과를 확인했다 - [ ] archive 이동을 승인했다 diff --git a/agent-ops/roadmap/phase/control-plane-portal-ops/milestones/policy-history-audit.md b/agent-ops/roadmap/phase/control-plane-portal-ops/milestones/policy-history-audit.md index 7c606e3..365025d 100644 --- a/agent-ops/roadmap/phase/control-plane-portal-ops/milestones/policy-history-audit.md +++ b/agent-ops/roadmap/phase/control-plane-portal-ops/milestones/policy-history-audit.md @@ -31,26 +31,22 @@ - timeout, 동시 실행 제한, 입력/출력 기록 정책 - metrics/event API와 운영 리포트 후보 -## 필수 기능 +## 기능 ### Epic: [policy-audit-boundary] Policy and Audit Boundary - [ ] [edge-source] Edge는 로컬 런타임 상태와 실행 이력의 원본을 유지한다. - [ ] [cp-aggregation] Control Plane은 Edge 데이터를 조회/집계하되 Edge 내부 상태 소유권을 대체하지 않는다. -- [ ] [terminal-audit] 원격 터미널 브리지는 target allowlist, credential 관리, command/session audit를 필수로 요구한다. +- [ ] [terminal-audit] 원격 터미널 브리지는 target allowlist, credential 관리, command/session audit를 필수로 요구하고, 실행 요청, command, terminal session, bootstrap/enrollment audit event 초안을 포함한다. +- [ ] [policy-location] 권한과 policy가 Edge/Control Plane/Node 경계 중 어디에서 적용되는지 문서화된다. +- [ ] [history-attach] 기존 실행 파이프라인을 깨지 않고 history/audit 수집을 붙일 수 있는 경계를 정의한다. - [ ] [schema-evidence] policy/audit 상세 schema는 구현 단계에서 명확한 evidence와 함께 확정한다. -## 완료 기준 - -- [ ] 실행 요청, command, terminal session, bootstrap/enrollment에 대한 audit event 초안이 있다. -- [ ] 권한과 policy가 Edge/Control Plane/Node 경계 중 어디에서 적용되는지 문서화된다. -- [ ] 기존 실행 파이프라인을 깨지 않고 history/audit 수집을 붙일 수 있다. - ## 완료 리뷰 - 상태: 없음 - 요청일: 없음 -- 완료 근거: 모든 필수 Task와 완료 기준이 아직 모두 충족되지 않았다. +- 완료 근거: 모든 기능 Task가 아직 충족되지 않았다. - 리뷰 필요: - [ ] 사용자가 완료 결과를 확인했다 - [ ] archive 이동을 승인했다 diff --git a/agent-ops/roadmap/phase/serving-routing-optimization/PHASE.md b/agent-ops/roadmap/phase/serving-routing-optimization/PHASE.md index 4cce7b7..73bac59 100644 --- a/agent-ops/roadmap/phase/serving-routing-optimization/PHASE.md +++ b/agent-ops/roadmap/phase/serving-routing-optimization/PHASE.md @@ -13,9 +13,9 @@ 완료된 Milestone은 archive 경로를 가리키고, 검토중, 진행중, 계획 또는 보류 Milestone은 이 Phase 하위 `milestones/` 경로를 가리킨다. 완료, 검토중, 진행중, 계획 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다. -- [진행중] Ollama E2E 서빙 안정화 - - 경로: `agent-ops/roadmap/phase/serving-routing-optimization/milestones/model-serving-load-routing.md` - - 요약: Edge OpenAI-compatible API를 통해 Node에서 serving 중인 Ollama를 일반 OpenAI/Ollama client 경험에 가깝게 조회, 선택, 호출하는 E2E 한 사이클을 먼저 완성하고, 자동 라우팅과 cloud fallback은 후속으로 미룬다. +- [완료] Ollama E2E 서빙 안정화 + - 경로: `agent-ops/roadmap/archive/phase/serving-routing-optimization/milestones/model-serving-load-routing.md` + - 요약: Edge OpenAI-compatible API를 통해 Node에서 serving 중인 Ollama를 일반 OpenAI/Ollama client 경험에 가깝게 조회, 선택, 호출하는 E2E 한 사이클과 Ollama option/API passthrough를 완료했다. 자동 라우팅, cloud fallback, 품질 평가는 후속으로 미룬다. - [계획] 지식, 도구 정책, 검증 최적화 - 경로: `agent-ops/roadmap/phase/serving-routing-optimization/milestones/knowledge-tool-validation-optimization.md` diff --git a/agent-ops/roadmap/phase/serving-routing-optimization/milestones/knowledge-tool-validation-optimization.md b/agent-ops/roadmap/phase/serving-routing-optimization/milestones/knowledge-tool-validation-optimization.md index b505fbb..9abb0c1 100644 --- a/agent-ops/roadmap/phase/serving-routing-optimization/milestones/knowledge-tool-validation-optimization.md +++ b/agent-ops/roadmap/phase/serving-routing-optimization/milestones/knowledge-tool-validation-optimization.md @@ -30,26 +30,21 @@ NomadCode는 이 계층의 소비자 중 하나이며, 이 Milestone은 특정 A - output validation, retry/fallback, token/속도/품질 최적화 기준 - 모델 호출 로그와 품질 평가 결과를 후속 routing 판단에 연결하는 방향 -## 필수 기능 +## 기능 ### Epic: [knowledge-policy] Knowledge and Tool Policy -- [ ] [knowledge-policy] RAG, context, web search, MCP, tool policy, validation은 IOP 책임으로 정의한다. -- [ ] [client-surface] 외부 client는 NomadCode 전용 metadata가 아니라 OpenAI-compatible, A2A, IOP native protocol 경계 중 맞는 표면을 통해 IOP를 소비한다. +- [ ] [knowledge-policy] 지식 소스, context 구성/압축, MCP/tool policy, validation/fallback 책임 경계가 IOP 책임으로 문서화되어 있다. +- [ ] [web-search-context] web search는 RAG/context 최적화 후보에 포함되어 IOP 지식 소스 경계에서 다룬다. +- [ ] [client-surface] NomadCode와 다른 외부 agent는 NomadCode 전용 metadata가 아니라 OpenAI-compatible, A2A, IOP native protocol 경계 중 맞는 표면으로 같은 IOP 책임 경계를 소비한다. - [ ] [shell-boundary] 특정 Agent Shell이나 Project Workspace UI 요구가 IOP 최적화 책임을 오염시키지 않도록 범위를 분리한다. - [ ] [validation-policy] 검증과 fallback은 모델 선택/라우팅 결과를 보완하는 IOP 내부 정책으로 정의한다. -## 완료 기준 - -- [ ] 지식 소스, context 구성, MCP/tool policy, validation/fallback 책임 경계가 문서화된다. -- [ ] web search가 RAG/context 최적화 후보에 포함된다. -- [ ] NomadCode와 다른 외부 agent가 같은 IOP 책임 경계를 소비할 수 있음이 설명된다. - ## 완료 리뷰 - 상태: 없음 - 요청일: 없음 -- 완료 근거: 모든 필수 Task와 완료 기준이 아직 모두 충족되지 않았다. +- 완료 근거: 모든 기능 Task가 아직 충족되지 않았다. - 리뷰 필요: - [ ] 사용자가 완료 결과를 확인했다 - [ ] archive 이동을 승인했다 diff --git a/apps/edge/internal/openai/server.go b/apps/edge/internal/openai/server.go index 37ecf2f..b41a1fc 100644 --- a/apps/edge/internal/openai/server.go +++ b/apps/edge/internal/openai/server.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net" "net/http" "strings" @@ -18,6 +19,7 @@ import ( type runService interface { SubmitRun(context.Context, edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error) + OllamaAPI(context.Context, edgeservice.OllamaAPIRequest) (edgeservice.OllamaAPIView, error) } type Server struct { @@ -50,6 +52,7 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("/healthz", s.handleHealthz) mux.HandleFunc("/v1/models", s.handleModels) mux.HandleFunc("/v1/chat/completions", s.handleChatCompletions) + mux.HandleFunc("/api/", s.handleOllamaAPI) s.server = &http.Server{ Addr: s.cfg.Listen, @@ -140,6 +143,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { Target: target, SessionID: s.resolveSessionID(), Prompt: prompt, + Input: req.runInput(prompt), TimeoutSec: s.resolveTimeoutSec(), Metadata: map[string]string{ "source": "openai", @@ -160,6 +164,42 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { s.completeChatCompletion(w, r, req, handle) } +func (s *Server) handleOllamaAPI(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodPost && r.Method != http.MethodDelete { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + defer r.Body.Close() + body, err := io.ReadAll(io.LimitReader(r.Body, 16<<20)) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request_error", "read request body failed") + return + } + resp, err := s.service.OllamaAPI(r.Context(), edgeservice.OllamaAPIRequest{ + NodeRef: s.cfg.NodeRef, + Adapter: s.resolveAdapter(), + Method: r.Method, + Path: r.URL.RequestURI(), + Body: string(body), + TimeoutSec: s.resolveTimeoutSec(), + }) + if err != nil { + writeError(w, http.StatusBadGateway, "ollama_passthrough_error", err.Error()) + return + } + contentType := resp.ContentType + if contentType == "" { + contentType = "application/json" + } + w.Header().Set("Content-Type", contentType) + status := resp.StatusCode + if status < 100 || status > 999 { + status = http.StatusOK + } + w.WriteHeader(status) + _, _ = w.Write([]byte(resp.Body)) +} + func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle *edgeservice.RunHandle) { text, reasoning, usage, err := collectRunResult(r.Context(), handle) if err != nil { @@ -417,15 +457,23 @@ func writeSSEError(w http.ResponseWriter, flusher http.Flusher, message string) } type chatCompletionRequest struct { - Model string `json:"model"` - Messages []chatMessage `json:"messages"` - Stream bool `json:"stream"` + Model string `json:"model"` + Messages []chatMessage `json:"messages"` + Stream bool `json:"stream"` + Options map[string]any `json:"options,omitempty"` + Format any `json:"format,omitempty"` + KeepAlive any `json:"keep_alive,omitempty"` + Think any `json:"think,omitempty"` + Tools []any `json:"tools,omitempty"` } type chatMessage struct { Role string `json:"role"` Content string `json:"content"` ReasoningContent string `json:"reasoning_content,omitempty"` + Images []any `json:"images,omitempty"` + ToolCalls []any `json:"tool_calls,omitempty"` + ToolName string `json:"tool_name,omitempty"` } func (m *chatMessage) UnmarshalJSON(b []byte) error { @@ -433,6 +481,9 @@ func (m *chatMessage) UnmarshalJSON(b []byte) error { Role string `json:"role"` Content any `json:"content"` ReasoningContent string `json:"reasoning_content,omitempty"` + Images []any `json:"images,omitempty"` + ToolCalls []any `json:"tool_calls,omitempty"` + ToolName string `json:"tool_name,omitempty"` } if err := json.Unmarshal(b, &raw); err != nil { return err @@ -440,9 +491,59 @@ func (m *chatMessage) UnmarshalJSON(b []byte) error { m.Role = raw.Role m.Content = contentToString(raw.Content) m.ReasoningContent = raw.ReasoningContent + m.Images = raw.Images + m.ToolCalls = raw.ToolCalls + m.ToolName = raw.ToolName return nil } +func (req chatCompletionRequest) runInput(prompt string) map[string]any { + input := map[string]any{ + "prompt": prompt, + "messages": chatMessagesInput(req.Messages), + } + if len(req.Options) > 0 { + input["options"] = req.Options + } + if req.Format != nil { + input["format"] = req.Format + } + if req.KeepAlive != nil { + input["keep_alive"] = req.KeepAlive + } + if req.Think != nil { + input["think"] = req.Think + } + if len(req.Tools) > 0 { + input["tools"] = req.Tools + } + return input +} + +func chatMessagesInput(messages []chatMessage) []any { + out := make([]any, 0, len(messages)) + for _, msg := range messages { + m := map[string]any{ + "role": msg.Role, + "content": msg.Content, + } + if msg.ReasoningContent != "" { + m["thinking"] = msg.ReasoningContent + } + if len(msg.Images) > 0 { + m["images"] = msg.Images + } + if len(msg.ToolCalls) > 0 { + m["tool_calls"] = msg.ToolCalls + } + if msg.ToolName != "" { + m["tool_name"] = msg.ToolName + } + out = append(out, m) + } + return out +} + func contentToString(v any) string { switch t := v.(type) { case string: diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go index 0bc1491..6f2cd9e 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -15,8 +15,10 @@ import ( ) type fakeRunService struct { - req edgeservice.SubmitRunRequest - events chan *iop.RunEvent + req edgeservice.SubmitRunRequest + ollamaReq edgeservice.OllamaAPIRequest + ollamaResp edgeservice.OllamaAPIView + events chan *iop.RunEvent } func (s *fakeRunService) SubmitRun(_ context.Context, req edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error) { @@ -34,6 +36,14 @@ func (s *fakeRunService) SubmitRun(_ context.Context, req edgeservice.SubmitRunR }, nil } +func (s *fakeRunService) OllamaAPI(_ context.Context, req edgeservice.OllamaAPIRequest) (edgeservice.OllamaAPIView, error) { + s.ollamaReq = req + if s.ollamaResp.StatusCode == 0 { + s.ollamaResp.StatusCode = http.StatusOK + } + return s.ollamaResp, nil +} + func TestChatCompletionsDispatchesConfiguredOllamaTarget(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "hello"} @@ -101,6 +111,43 @@ func TestChatCompletionsUsesRequestModelWhenNoConfiguredTarget(t *testing.T) { } } +func TestChatCompletionsPassesOllamaOptions(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} + fake.events <- &iop.RunEvent{Type: "complete"} + + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"from-request", + "messages":[{"role":"user","content":"hi"}], + "options":{"temperature":0.2,"top_p":0.9,"num_predict":32,"stop":["END"]}, + "keep_alive":"10m", + "think":false, + "format":"json", + "tools":[{"type":"function","function":{"name":"lookup"}}] + }`)) + w := httptest.NewRecorder() + + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + options, ok := fake.req.Input["options"].(map[string]any) + if !ok { + t.Fatalf("options not passed: %+v", fake.req.Input) + } + if options["temperature"].(float64) != 0.2 || options["top_p"].(float64) != 0.9 || options["num_predict"].(float64) != 32 { + t.Fatalf("unexpected options: %+v", options) + } + if fake.req.Input["keep_alive"] != "10m" || fake.req.Input["think"] != false || fake.req.Input["format"] != "json" { + t.Fatalf("top-level ollama fields not passed: %+v", fake.req.Input) + } + if tools, ok := fake.req.Input["tools"].([]any); !ok || len(tools) != 1 { + t.Fatalf("tools not passed: %+v", fake.req.Input["tools"]) + } +} + func TestChatCompletionsStreamsSSE(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "hi"} @@ -205,6 +252,34 @@ func TestModelsUsesConfiguredModelsOrTarget(t *testing.T) { } } +func TestOllamaAPIPassthrough(t *testing.T) { + fake := &fakeRunService{ + ollamaResp: edgeservice.OllamaAPIView{ + StatusCode: http.StatusAccepted, + ContentType: "application/json", + Body: `{"ok":true}`, + }, + } + srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", TimeoutSec: 15}, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(`{"model":"gemma4:26b"}`)) + w := httptest.NewRecorder() + + srv.handleOllamaAPI(w, req) + + if w.Code != http.StatusAccepted { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + if fake.ollamaReq.Adapter != "ollama" || fake.ollamaReq.Method != http.MethodPost || fake.ollamaReq.Path != "/api/show" { + t.Fatalf("passthrough req mismatch: %+v", fake.ollamaReq) + } + if fake.ollamaReq.Body != `{"model":"gemma4:26b"}` || fake.ollamaReq.TimeoutSec != 15 { + t.Fatalf("passthrough body/timeout mismatch: %+v", fake.ollamaReq) + } + if w.Body.String() != `{"ok":true}` { + t.Fatalf("body: got %s", w.Body.String()) + } +} + func TestCollectRunResultTimesOut(t *testing.T) { handle := &edgeservice.RunHandle{ RunDispatch: edgeservice.RunDispatch{TimeoutSec: 1}, diff --git a/apps/edge/internal/service/service.go b/apps/edge/internal/service/service.go index 0d41d6c..9e2c09c 100644 --- a/apps/edge/internal/service/service.go +++ b/apps/edge/internal/service/service.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "strconv" "time" toki "git.toki-labs.com/toki/proto-socket/go" @@ -83,6 +84,7 @@ type SubmitRunRequest struct { Target string SessionID string Prompt string + Input map[string]any Background bool TimeoutSec int Metadata map[string]string @@ -380,6 +382,55 @@ func (s *Service) TransportStatus(_ context.Context, req NodeCommandRequestSpec) return s.sendNodeCommand(req, iop.NodeCommandType_NODE_COMMAND_TYPE_TRANSPORT_STATUS, "transport") } +type OllamaAPIRequest struct { + NodeRef string + Adapter string + Target string + Method string + Path string + Body string + TimeoutSec int +} + +type OllamaAPIView struct { + StatusCode int + ContentType string + Body string +} + +func (s *Service) OllamaAPI(_ context.Context, req OllamaAPIRequest) (OllamaAPIView, error) { + entry, err := s.ResolveNode(req.NodeRef) + if err != nil { + return OllamaAPIView{}, err + } + commandReq := buildNodeCommandRequest(iop.NodeCommandType_NODE_COMMAND_TYPE_OLLAMA_API, "ollama", req.Adapter, req.Target, "", req.TimeoutSec) + commandReq.Metadata = map[string]string{ + "ollama_method": req.Method, + "ollama_path": req.Path, + "ollama_body": req.Body, + } + resp, err := toki.SendRequestTyped[*iop.NodeCommandRequest, *iop.NodeCommandResponse]( + &entry.Client.Communicator, + commandReq, + StatusWaitTimeout(commandReq), + ) + if err != nil { + return OllamaAPIView{}, fmt.Errorf("transport error: %w", err) + } + if resp.GetError() != "" { + return OllamaAPIView{}, fmt.Errorf("node reported error: %s", resp.GetError()) + } + statusCode, _ := strconv.Atoi(resp.GetResult()["status_code"]) + if statusCode == 0 { + statusCode = 200 + } + return OllamaAPIView{ + StatusCode: statusCode, + ContentType: resp.GetResult()["content_type"], + Body: resp.GetResult()["body"], + }, nil +} + func (s *Service) sendNodeCommand(req NodeCommandRequestSpec, cmdType iop.NodeCommandType, idPrefix string) (NodeCommandView, error) { entry, err := s.ResolveNode(req.NodeRef) if err != nil { @@ -424,7 +475,16 @@ func IsNodeDisconnected(event *iop.EdgeNodeEvent) bool { } func BuildRunRequest(req SubmitRunRequest) (*iop.RunRequest, string, error) { - input, err := structpb.NewStruct(map[string]any{"prompt": req.Prompt}) + inputMap := make(map[string]any, len(req.Input)+1) + for k, v := range req.Input { + inputMap[k] = v + } + if req.Prompt != "" { + if _, ok := inputMap["prompt"]; !ok { + inputMap["prompt"] = req.Prompt + } + } + input, err := structpb.NewStruct(inputMap) if err != nil { return nil, "", err } diff --git a/apps/node/internal/adapters/ollama/ollama.go b/apps/node/internal/adapters/ollama/ollama.go index 45d7bc8..0c7c5f7 100644 --- a/apps/node/internal/adapters/ollama/ollama.go +++ b/apps/node/internal/adapters/ollama/ollama.go @@ -77,8 +77,20 @@ func (o *Ollama) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink r Messages: messages, Stream: true, } - if o.contextSize > 0 { - chatReq.Options = &ollamaOptions{NumCtx: o.contextSize} + if options := ollamaOptionsFromInput(spec.Input, o.contextSize); len(options) > 0 { + chatReq.Options = options + } + if v, ok := spec.Input["format"]; ok { + chatReq.Format = v + } + if v, ok := spec.Input["keep_alive"]; ok { + chatReq.KeepAlive = v + } + if v, ok := spec.Input["think"]; ok { + chatReq.Think = v + } + if v, ok := spec.Input["tools"]; ok { + chatReq.Tools = v } body, err := json.Marshal(chatReq) if err != nil { @@ -199,6 +211,53 @@ func (o *Ollama) fetchTargets() []string { return out } +func (o *Ollama) HandleCommand(ctx context.Context, req runtime.CommandRequest) (runtime.CommandResponse, error) { + if req.Type != runtime.CommandTypeOllamaAPI { + return runtime.CommandResponse{}, fmt.Errorf("ollama adapter: unsupported command %q", req.Type) + } + method := strings.ToUpper(strings.TrimSpace(req.Metadata["ollama_method"])) + if method == "" { + method = http.MethodGet + } + if method != http.MethodGet && method != http.MethodPost && method != http.MethodDelete { + return runtime.CommandResponse{}, fmt.Errorf("ollama adapter: unsupported method %q", method) + } + path := strings.TrimSpace(req.Metadata["ollama_path"]) + if !strings.HasPrefix(path, "/api/") { + return runtime.CommandResponse{}, fmt.Errorf("ollama adapter: unsupported path %q", path) + } + body := req.Metadata["ollama_body"] + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + httpReq, err := http.NewRequestWithContext(ctx, method, joinURLRequestURI(o.baseURL, path), reader) + if err != nil { + return runtime.CommandResponse{}, fmt.Errorf("ollama adapter: build passthrough request: %w", err) + } + if body != "" { + httpReq.Header.Set("Content-Type", "application/json") + } + resp, err := o.client.Do(httpReq) + if err != nil { + return runtime.CommandResponse{}, fmt.Errorf("ollama adapter: passthrough request: %w", err) + } + defer resp.Body.Close() + respBody := readLimited(resp.Body, 16<<20) + return runtime.CommandResponse{ + RequestID: req.RequestID, + Type: req.Type, + Adapter: req.Adapter, + Target: req.Target, + SessionID: req.SessionID, + Result: map[string]string{ + "status_code": fmt.Sprintf("%d", resp.StatusCode), + "content_type": resp.Header.Get("Content-Type"), + "body": respBody, + }, + }, nil +} + func ollamaMessagesFromInput(input map[string]any) []ollamaMessage { if raw, ok := input["messages"].([]any); ok { out := make([]ollamaMessage, 0, len(raw)) @@ -209,12 +268,21 @@ func ollamaMessagesFromInput(input map[string]any) []ollamaMessage { } role, _ := m["role"].(string) content, _ := m["content"].(string) + thinking, _ := m["thinking"].(string) + toolName, _ := m["tool_name"].(string) role = strings.TrimSpace(role) content = strings.TrimSpace(content) - if role == "" || content == "" { + if role == "" || (content == "" && thinking == "") { continue } - out = append(out, ollamaMessage{Role: role, Content: content}) + out = append(out, ollamaMessage{ + Role: role, + Content: content, + Thinking: thinking, + Images: anySlice(m["images"]), + ToolCalls: anySlice(m["tool_calls"]), + ToolName: toolName, + }) } if len(out) > 0 { return out @@ -227,6 +295,31 @@ func ollamaMessagesFromInput(input map[string]any) []ollamaMessage { return []ollamaMessage{{Role: "user", Content: prompt}} } +func ollamaOptionsFromInput(input map[string]any, contextSize int) map[string]any { + options := map[string]any{} + if raw, ok := input["options"].(map[string]any); ok { + for k, v := range raw { + options[k] = v + } + } + if contextSize > 0 { + if _, ok := options["num_ctx"]; !ok { + options["num_ctx"] = contextSize + } + } + if len(options) == 0 { + return nil + } + return options +} + +func anySlice(v any) []any { + if items, ok := v.([]any); ok { + return items + } + return nil +} + func stringInput(input map[string]any, key string) string { if input == nil { return "" @@ -263,26 +356,44 @@ func joinURL(baseURL, path string) string { return u.String() } +func joinURLRequestURI(baseURL, requestURI string) string { + u, err := url.Parse(baseURL) + if err != nil { + return strings.TrimRight(baseURL, "/") + requestURI + } + rel, err := url.Parse(requestURI) + if err != nil { + u.Path = strings.TrimRight(u.Path, "/") + requestURI + return u.String() + } + u.Path = strings.TrimRight(u.Path, "/") + rel.Path + u.RawQuery = rel.RawQuery + return u.String() +} + func readLimited(r io.Reader, limit int64) string { b, _ := io.ReadAll(io.LimitReader(r, limit)) return strings.TrimSpace(string(b)) } type ollamaChatRequest struct { - Model string `json:"model"` - Messages []ollamaMessage `json:"messages"` - Stream bool `json:"stream"` - Options *ollamaOptions `json:"options,omitempty"` -} - -type ollamaOptions struct { - NumCtx int `json:"num_ctx,omitempty"` + Model string `json:"model"` + Messages []ollamaMessage `json:"messages"` + Stream bool `json:"stream"` + Options map[string]any `json:"options,omitempty"` + Format any `json:"format,omitempty"` + KeepAlive any `json:"keep_alive,omitempty"` + Think any `json:"think,omitempty"` + Tools any `json:"tools,omitempty"` } type ollamaMessage struct { - Role string `json:"role"` - Content string `json:"content"` - Thinking string `json:"thinking,omitempty"` + Role string `json:"role"` + Content string `json:"content"` + Thinking string `json:"thinking,omitempty"` + Images []any `json:"images,omitempty"` + ToolCalls []any `json:"tool_calls,omitempty"` + ToolName string `json:"tool_name,omitempty"` } type ollamaChatChunk struct { diff --git a/apps/node/internal/adapters/ollama/ollama_test.go b/apps/node/internal/adapters/ollama/ollama_test.go index 7df7d31..807c306 100644 --- a/apps/node/internal/adapters/ollama/ollama_test.go +++ b/apps/node/internal/adapters/ollama/ollama_test.go @@ -47,7 +47,12 @@ func TestOllamaExecuteStreamsChatDeltas(t *testing.T) { } gotModel = req.Model if req.Options != nil { - gotNumCtx = req.Options.NumCtx + switch v := req.Options["num_ctx"].(type) { + case float64: + gotNumCtx = int(v) + case int: + gotNumCtx = v + } } if len(req.Messages) != 1 { t.Fatalf("expected one message, got %+v", req.Messages) @@ -98,6 +103,57 @@ func TestOllamaExecuteStreamsChatDeltas(t *testing.T) { } } +func TestOllamaExecutePassesOptionsAndTopLevelFields(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/chat" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + var req ollamaChatRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req.Options["temperature"].(float64) != 0.2 || req.Options["top_p"].(float64) != 0.9 || req.Options["num_predict"].(float64) != 32 { + t.Fatalf("unexpected options: %+v", req.Options) + } + if req.Options["num_ctx"].(float64) != 4096 { + t.Fatalf("request num_ctx should override config context size: %+v", req.Options) + } + if req.KeepAlive != "10m" || req.Think != false || req.Format != "json" { + t.Fatalf("top-level fields not passed: keep_alive=%v think=%v format=%v", req.KeepAlive, req.Think, req.Format) + } + if tools, ok := req.Tools.([]any); !ok || len(tools) != 1 { + t.Fatalf("tools not passed: %+v", req.Tools) + } + w.Header().Set("Content-Type", "application/x-ndjson") + _, _ = w.Write([]byte(`{"message":{"role":"assistant","content":"ok"},"done":false}` + "\n")) + _, _ = w.Write([]byte(`{"done":true}` + "\n")) + })) + defer server.Close() + + adapter := New(config.OllamaConf{BaseURL: server.URL, ContextSize: 262144}, zap.NewNop()) + sink := &fakeSink{} + err := adapter.Execute(context.Background(), noderuntime.ExecutionSpec{ + RunID: "run-options", + Target: "llama-test", + Input: map[string]any{ + "prompt": "hi", + "options": map[string]any{ + "temperature": 0.2, + "top_p": 0.9, + "num_predict": float64(32), + "num_ctx": float64(4096), + }, + "keep_alive": "10m", + "think": false, + "format": "json", + "tools": []any{map[string]any{"type": "function"}}, + }, + }, sink) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } +} + func TestOllamaExecuteEmitsErrorForHTTPFailure(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "boom", http.StatusBadGateway) @@ -143,6 +199,43 @@ func TestOllamaCapabilitiesQueryTags(t *testing.T) { } } +func TestOllamaHandleCommandPassesNativeAPI(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.RequestURI() != "/api/show?verbose=true" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.RequestURI()) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body["model"] != "gemma4:26b" { + t.Fatalf("body not passed: %+v", body) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"license":"ok"}`)) + })) + defer server.Close() + + adapter := New(config.OllamaConf{BaseURL: server.URL}, zap.NewNop()) + resp, err := adapter.HandleCommand(context.Background(), noderuntime.CommandRequest{ + RequestID: "ollama-1", + Type: noderuntime.CommandTypeOllamaAPI, + Adapter: "ollama", + Metadata: map[string]string{ + "ollama_method": "POST", + "ollama_path": "/api/show?verbose=true", + "ollama_body": `{"model":"gemma4:26b"}`, + }, + }) + if err != nil { + t.Fatalf("HandleCommand failed: %v", err) + } + if resp.Result["status_code"] != "202" || resp.Result["content_type"] != "application/json" || resp.Result["body"] != `{"license":"ok"}` { + t.Fatalf("unexpected response: %+v", resp.Result) + } +} + func TestOllamaExecuteStreamsThinkingDeltasSeparately(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/chat" { diff --git a/apps/node/internal/node/node.go b/apps/node/internal/node/node.go index 0c34455..1eccd13 100644 --- a/apps/node/internal/node/node.go +++ b/apps/node/internal/node/node.go @@ -309,6 +309,8 @@ func protoCommandTypeToDomain(t iop.NodeCommandType) (runtime.CommandType, bool) return runtime.CommandTypeSessionList, true case iop.NodeCommandType_NODE_COMMAND_TYPE_TRANSPORT_STATUS: return runtime.CommandTypeTransportStatus, true + case iop.NodeCommandType_NODE_COMMAND_TYPE_OLLAMA_API: + return runtime.CommandTypeOllamaAPI, true default: return "", false } @@ -324,6 +326,8 @@ func domainCommandTypeToProto(t runtime.CommandType) iop.NodeCommandType { return iop.NodeCommandType_NODE_COMMAND_TYPE_SESSION_LIST case runtime.CommandTypeTransportStatus: return iop.NodeCommandType_NODE_COMMAND_TYPE_TRANSPORT_STATUS + case runtime.CommandTypeOllamaAPI: + return iop.NodeCommandType_NODE_COMMAND_TYPE_OLLAMA_API default: return iop.NodeCommandType_NODE_COMMAND_TYPE_UNSPECIFIED } diff --git a/apps/node/internal/runtime/types.go b/apps/node/internal/runtime/types.go index df36510..2f07710 100644 --- a/apps/node/internal/runtime/types.go +++ b/apps/node/internal/runtime/types.go @@ -109,6 +109,7 @@ const ( CommandTypeCapabilities CommandType = "capabilities" CommandTypeSessionList CommandType = "session_list" CommandTypeTransportStatus CommandType = "transport_status" + CommandTypeOllamaAPI CommandType = "ollama_api" ) type AgentUsageStatus struct { diff --git a/proto/gen/iop/runtime.pb.go b/proto/gen/iop/runtime.pb.go index f93fae5..88907d5 100644 --- a/proto/gen/iop/runtime.pb.go +++ b/proto/gen/iop/runtime.pb.go @@ -128,6 +128,7 @@ const ( NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES NodeCommandType = 2 NodeCommandType_NODE_COMMAND_TYPE_SESSION_LIST NodeCommandType = 3 NodeCommandType_NODE_COMMAND_TYPE_TRANSPORT_STATUS NodeCommandType = 4 + NodeCommandType_NODE_COMMAND_TYPE_OLLAMA_API NodeCommandType = 5 ) // Enum value maps for NodeCommandType. @@ -138,6 +139,7 @@ var ( 2: "NODE_COMMAND_TYPE_CAPABILITIES", 3: "NODE_COMMAND_TYPE_SESSION_LIST", 4: "NODE_COMMAND_TYPE_TRANSPORT_STATUS", + 5: "NODE_COMMAND_TYPE_OLLAMA_API", } NodeCommandType_value = map[string]int32{ "NODE_COMMAND_TYPE_UNSPECIFIED": 0, @@ -145,6 +147,7 @@ var ( "NODE_COMMAND_TYPE_CAPABILITIES": 2, "NODE_COMMAND_TYPE_SESSION_LIST": 3, "NODE_COMMAND_TYPE_TRANSPORT_STATUS": 4, + "NODE_COMMAND_TYPE_OLLAMA_API": 5, } ) @@ -1881,13 +1884,14 @@ const file_proto_iop_runtime_proto_rawDesc = "" + "\fCancelAction\x12\x1d\n" + "\x19CANCEL_ACTION_UNSPECIFIED\x10\x00\x12\x1c\n" + "\x18CANCEL_ACTION_CANCEL_RUN\x10\x01\x12#\n" + - "\x1fCANCEL_ACTION_TERMINATE_SESSION\x10\x02*\xc8\x01\n" + + "\x1fCANCEL_ACTION_TERMINATE_SESSION\x10\x02*\xea\x01\n" + "\x0fNodeCommandType\x12!\n" + "\x1dNODE_COMMAND_TYPE_UNSPECIFIED\x10\x00\x12\"\n" + "\x1eNODE_COMMAND_TYPE_USAGE_STATUS\x10\x01\x12\"\n" + "\x1eNODE_COMMAND_TYPE_CAPABILITIES\x10\x02\x12\"\n" + "\x1eNODE_COMMAND_TYPE_SESSION_LIST\x10\x03\x12&\n" + - "\"NODE_COMMAND_TYPE_TRANSPORT_STATUS\x10\x04B\x13Z\x11iop/proto/gen/iopb\x06proto3" + "\"NODE_COMMAND_TYPE_TRANSPORT_STATUS\x10\x04\x12 \n" + + "\x1cNODE_COMMAND_TYPE_OLLAMA_API\x10\x05B\x13Z\x11iop/proto/gen/iopb\x06proto3" var ( file_proto_iop_runtime_proto_rawDescOnce sync.Once diff --git a/proto/iop/runtime.proto b/proto/iop/runtime.proto index 381753f..a897ed6 100644 --- a/proto/iop/runtime.proto +++ b/proto/iop/runtime.proto @@ -87,6 +87,7 @@ enum NodeCommandType { NODE_COMMAND_TYPE_CAPABILITIES = 2; NODE_COMMAND_TYPE_SESSION_LIST = 3; NODE_COMMAND_TYPE_TRANSPORT_STATUS = 4; + NODE_COMMAND_TYPE_OLLAMA_API = 5; } message NodeCommandRequest {